@cat-factory/app 0.6.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 (189) 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 +143 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AddTaskModal.vue +444 -0
  10. package/app/components/board/AgentFailureCard.vue +97 -0
  11. package/app/components/board/AgentStopButton.vue +61 -0
  12. package/app/components/board/BoardCanvas.vue +183 -0
  13. package/app/components/board/ContextPicker.vue +367 -0
  14. package/app/components/board/RecurringPipelineModal.vue +219 -0
  15. package/app/components/board/TaskDependencyEdges.vue +132 -0
  16. package/app/components/board/nodes/AgentChip.vue +59 -0
  17. package/app/components/board/nodes/BlockNode.vue +433 -0
  18. package/app/components/board/nodes/DecisionBadge.vue +27 -0
  19. package/app/components/board/nodes/DraggableTask.vue +48 -0
  20. package/app/components/board/nodes/ModuleFrame.vue +97 -0
  21. package/app/components/board/nodes/TaskCard.vue +359 -0
  22. package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
  23. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  24. package/app/components/clarity/ClarityReviewWindow.vue +611 -0
  25. package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
  26. package/app/components/documents/DocumentImportModal.vue +161 -0
  27. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  28. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  29. package/app/components/documents/TaskContextDocs.vue +83 -0
  30. package/app/components/focus/BlockFocusView.vue +171 -0
  31. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  32. package/app/components/gates/GateResultView.vue +282 -0
  33. package/app/components/github/AddServiceFromRepoModal.vue +354 -0
  34. package/app/components/github/GitHubConnect.vue +183 -0
  35. package/app/components/github/GitHubOnboarding.vue +45 -0
  36. package/app/components/github/GitHubPanel.vue +584 -0
  37. package/app/components/github/RepoTreeBrowser.vue +171 -0
  38. package/app/components/layout/AccountTeamSettings.vue +237 -0
  39. package/app/components/layout/BoardSwitcher.vue +280 -0
  40. package/app/components/layout/BoardToolbar.vue +156 -0
  41. package/app/components/layout/CommandBar.vue +336 -0
  42. package/app/components/layout/GitHubPatBanner.vue +73 -0
  43. package/app/components/layout/NotificationsInbox.vue +175 -0
  44. package/app/components/layout/SideBar.vue +314 -0
  45. package/app/components/layout/SpendWarningBanner.vue +107 -0
  46. package/app/components/observability/StepMetricsBar.vue +102 -0
  47. package/app/components/palettes/AgentPalette.vue +86 -0
  48. package/app/components/panels/AgentStepDetail.vue +737 -0
  49. package/app/components/panels/DecisionModal.vue +71 -0
  50. package/app/components/panels/InspectorPanel.vue +465 -0
  51. package/app/components/panels/ObservabilityPanel.vue +351 -0
  52. package/app/components/panels/StepMetadataCard.vue +253 -0
  53. package/app/components/panels/StepRestartControl.vue +90 -0
  54. package/app/components/panels/StepResultViewHost.vue +40 -0
  55. package/app/components/panels/StepTestReport.vue +84 -0
  56. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  57. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
  58. package/app/components/panels/inspector/ServiceFragments.vue +82 -0
  59. package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
  60. package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
  61. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  62. package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
  63. package/app/components/panels/inspector/TaskExecution.vue +364 -0
  64. package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
  65. package/app/components/panels/inspector/TaskStructure.vue +96 -0
  66. package/app/components/pipeline/AgentKindIcon.vue +30 -0
  67. package/app/components/pipeline/IterationCapPrompt.vue +70 -0
  68. package/app/components/pipeline/PipelineBuilder.vue +817 -0
  69. package/app/components/pipeline/PipelineProgress.vue +484 -0
  70. package/app/components/providers/ApiKeysSection.vue +273 -0
  71. package/app/components/providers/PersonalCredentialModal.vue +128 -0
  72. package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
  73. package/app/components/providers/VendorCredentialsModal.vue +197 -0
  74. package/app/components/recurring/RecurrenceEditor.vue +124 -0
  75. package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
  76. package/app/components/settings/DatadogPanel.vue +213 -0
  77. package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
  78. package/app/components/settings/MergeThresholdsPanel.vue +378 -0
  79. package/app/components/settings/ModelDefaultsPanel.vue +250 -0
  80. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
  81. package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
  82. package/app/components/slack/SlackPanel.vue +299 -0
  83. package/app/components/tasks/TaskContextIssues.vue +88 -0
  84. package/app/components/tasks/TaskImportModal.vue +207 -0
  85. package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
  86. package/app/components/testing/TestReportWindow.vue +404 -0
  87. package/app/composables/api/accounts.ts +81 -0
  88. package/app/composables/api/auth.ts +45 -0
  89. package/app/composables/api/board.ts +101 -0
  90. package/app/composables/api/bootstrap.ts +62 -0
  91. package/app/composables/api/context.ts +25 -0
  92. package/app/composables/api/documents.ts +74 -0
  93. package/app/composables/api/execution.ts +127 -0
  94. package/app/composables/api/fragments.ts +71 -0
  95. package/app/composables/api/github.ts +131 -0
  96. package/app/composables/api/models.ts +127 -0
  97. package/app/composables/api/notifications.ts +23 -0
  98. package/app/composables/api/presets.ts +29 -0
  99. package/app/composables/api/recurring.ts +68 -0
  100. package/app/composables/api/releaseHealth.ts +43 -0
  101. package/app/composables/api/reviews.ts +146 -0
  102. package/app/composables/api/slack.ts +54 -0
  103. package/app/composables/api/tasks.ts +72 -0
  104. package/app/composables/api/workspaces.ts +36 -0
  105. package/app/composables/useApi.ts +89 -0
  106. package/app/composables/useBlockDrag.ts +90 -0
  107. package/app/composables/useBlockQueries.ts +154 -0
  108. package/app/composables/useBoardFlow.ts +11 -0
  109. package/app/composables/useContextLinking.ts +65 -0
  110. package/app/composables/useDepLabels.ts +26 -0
  111. package/app/composables/useFrameResize.ts +54 -0
  112. package/app/composables/useResultView.ts +48 -0
  113. package/app/composables/useReviewStage.ts +40 -0
  114. package/app/composables/useSemanticZoom.ts +31 -0
  115. package/app/composables/useStepApproval.ts +233 -0
  116. package/app/composables/useStepProse.ts +78 -0
  117. package/app/composables/useStepTimer.ts +63 -0
  118. package/app/composables/useTaskExpansion.ts +92 -0
  119. package/app/composables/useWorkspaceStream.ts +155 -0
  120. package/app/docs/architecture.md +31 -0
  121. package/app/pages/index.vue +141 -0
  122. package/app/stores/accounts.ts +152 -0
  123. package/app/stores/agentConfig.ts +35 -0
  124. package/app/stores/agentRuns.ts +122 -0
  125. package/app/stores/agents.ts +40 -0
  126. package/app/stores/apiKeys.ts +108 -0
  127. package/app/stores/auth.ts +166 -0
  128. package/app/stores/board.spec.ts +205 -0
  129. package/app/stores/board.ts +286 -0
  130. package/app/stores/bootstrap.ts +97 -0
  131. package/app/stores/clarity.ts +196 -0
  132. package/app/stores/consensus.ts +60 -0
  133. package/app/stores/documents.ts +176 -0
  134. package/app/stores/execution.ts +273 -0
  135. package/app/stores/fragmentLibrary.ts +147 -0
  136. package/app/stores/fragments.ts +40 -0
  137. package/app/stores/github.ts +305 -0
  138. package/app/stores/localModels.ts +51 -0
  139. package/app/stores/mergePresets.ts +58 -0
  140. package/app/stores/modelDefaults.ts +76 -0
  141. package/app/stores/models.ts +134 -0
  142. package/app/stores/notifications.ts +70 -0
  143. package/app/stores/observability.ts +144 -0
  144. package/app/stores/personalSubscriptions.ts +215 -0
  145. package/app/stores/pipelines.ts +327 -0
  146. package/app/stores/recurringPipelines.ts +112 -0
  147. package/app/stores/releaseHealth.ts +75 -0
  148. package/app/stores/requirements.spec.ts +94 -0
  149. package/app/stores/requirements.ts +208 -0
  150. package/app/stores/serviceFragmentDefaults.ts +29 -0
  151. package/app/stores/services.ts +87 -0
  152. package/app/stores/slack.ts +142 -0
  153. package/app/stores/taskExpansion.ts +36 -0
  154. package/app/stores/tasks.spec.ts +71 -0
  155. package/app/stores/tasks.ts +176 -0
  156. package/app/stores/tracker.ts +27 -0
  157. package/app/stores/ui.ts +434 -0
  158. package/app/stores/vendorCredentials.ts +54 -0
  159. package/app/stores/workspace.ts +215 -0
  160. package/app/stores/workspaceSettings.ts +36 -0
  161. package/app/types/accounts.ts +77 -0
  162. package/app/types/bootstrap.ts +83 -0
  163. package/app/types/clarity.ts +59 -0
  164. package/app/types/consensus.ts +91 -0
  165. package/app/types/documents.ts +104 -0
  166. package/app/types/domain.ts +495 -0
  167. package/app/types/execution.ts +383 -0
  168. package/app/types/fragments.ts +72 -0
  169. package/app/types/github.ts +173 -0
  170. package/app/types/localModels.ts +73 -0
  171. package/app/types/merge.ts +71 -0
  172. package/app/types/models.ts +157 -0
  173. package/app/types/notifications.ts +74 -0
  174. package/app/types/recurring.ts +69 -0
  175. package/app/types/releaseHealth.ts +31 -0
  176. package/app/types/requirements.ts +61 -0
  177. package/app/types/services.ts +27 -0
  178. package/app/types/slack.ts +57 -0
  179. package/app/types/tasks.ts +82 -0
  180. package/app/types/tracker.ts +18 -0
  181. package/app/utils/agentOutput.spec.ts +128 -0
  182. package/app/utils/agentOutput.ts +173 -0
  183. package/app/utils/catalog.spec.ts +112 -0
  184. package/app/utils/catalog.ts +455 -0
  185. package/app/utils/dnd.ts +29 -0
  186. package/app/utils/observability.ts +52 -0
  187. package/app/utils/pipelineRender.ts +151 -0
  188. package/nuxt.config.ts +55 -0
  189. package/package.json +45 -0
@@ -0,0 +1,433 @@
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 DecisionBadge from './DecisionBadge.vue'
5
+ import DraggableTask from './DraggableTask.vue'
6
+ import ModuleFrame from './ModuleFrame.vue'
7
+ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
8
+ import AgentStopButton from '~/components/board/AgentStopButton.vue'
9
+ import { useBlockDrag } from '~/composables/useBlockDrag'
10
+ import { useFrameResize } from '~/composables/useFrameResize'
11
+
12
+ // Vue Flow passes the node's `id` and `data` as props to custom node components.
13
+ // Only frames are rendered as board nodes; their tasks live inside the card.
14
+ const props = defineProps<{ id: string }>()
15
+
16
+ const board = useBoardStore()
17
+ const execution = useExecutionStore()
18
+ const ui = useUiStore()
19
+ const agentRuns = useAgentRunsStore()
20
+ const services = useServicesStore()
21
+ const reviews = useReviewStage()
22
+ const { lod } = useSemanticZoom()
23
+
24
+ const block = computed<Block | undefined>(() => board.getBlock(props.id))
25
+ /** This service frame is mounted on more than one board in the org. */
26
+ const isShared = computed(() => services.isSharedFrame(props.id))
27
+ const typeMeta = computed(() => (block.value ? BLOCK_TYPE_META[block.value.type] : null))
28
+
29
+ // ---- this service's children (tasks + modules) -----------------------------
30
+ const directTasks = computed(() => board.tasksOf(props.id))
31
+ const modules = computed(() => board.modulesOf(props.id))
32
+ const allTasks = computed(() => board.allTasksUnder(props.id))
33
+ const taskIds = computed(() => new Set(allTasks.value.map((t) => t.id)))
34
+ const taskCount = computed(() => allTasks.value.length)
35
+ const hasTasks = computed(() => taskCount.value > 0 || modules.value.length > 0)
36
+ const mergedTasks = computed(() => allTasks.value.filter((t) => t.status === 'done').length)
37
+ const prTasks = computed(() => allTasks.value.filter((t) => t.status === 'pr_ready').length)
38
+ const canvas = computed(() => board.containerSize(props.id))
39
+
40
+ // Frame status is derived from its tasks — services never reach "done".
41
+ const frameStatus = computed<BlockStatus>(() => board.frameStatus(props.id))
42
+ const statusMeta = computed(() => STATUS_META[frameStatus.value])
43
+ const accent = computed(() => statusMeta.value.color)
44
+ const FRAME_LABEL: Record<BlockStatus, string> = {
45
+ planned: 'No tasks',
46
+ ready: 'Live',
47
+ in_progress: 'Active',
48
+ blocked: 'Needs attention',
49
+ pr_ready: 'Active',
50
+ done: 'Live',
51
+ }
52
+ const statusLabel = computed(() => FRAME_LABEL[frameStatus.value])
53
+
54
+ const selected = computed(() => ui.selectedBlockId === props.id)
55
+ const expanded = computed(() => ui.isFrameExpanded(props.id))
56
+ // At far zoom we only ever show the chip; otherwise an expanded frame shows tasks.
57
+ const showExpanded = computed(() => expanded.value && lod.value !== 'far')
58
+
59
+ // Surface a pending decision from this frame OR any of its tasks.
60
+ const blockDecisions = computed(() =>
61
+ execution.openDecisions.filter((d) => d.blockId === props.id || taskIds.value.has(d.blockId)),
62
+ )
63
+
64
+ function openFirstDecision() {
65
+ const d = blockDecisions.value[0]
66
+ if (d) ui.openDecision(d.instanceId, d.decision.id)
67
+ }
68
+
69
+ // Surface a pending approval gate from this frame OR any of its tasks — but NOT an
70
+ // iterative reviewer gate (requirements-review / clarity-review) that's mid-cycle
71
+ // (incorporating / re-reviewing in the driver), which is background work needing no human,
72
+ // so it stays off the frame's "Approval" badge.
73
+ const blockApprovals = computed(() =>
74
+ execution.openApprovals.filter(
75
+ (a) =>
76
+ (a.blockId === props.id || taskIds.value.has(a.blockId)) &&
77
+ !reviews.isBackground(a.agentKind, a.blockId),
78
+ ),
79
+ )
80
+
81
+ function openFirstApproval() {
82
+ const a = blockApprovals.value[0]
83
+ if (a) ui.openApprovalDetail(a.instanceId, a.approval.id)
84
+ }
85
+
86
+ function toggleExpand() {
87
+ ui.toggleFrame(props.id)
88
+ }
89
+
90
+ // Expanded frames are not Vue Flow-draggable (so the pane can pan through them),
91
+ // so they're repositioned by grabbing the header handle instead. Frames live in
92
+ // free-floating flow space, hence `clamp: false`.
93
+ const { startDrag } = useBlockDrag()
94
+ function onFrameHandle(e: PointerEvent) {
95
+ if (block.value) startDrag(block.value, e, { clamp: false })
96
+ }
97
+
98
+ // Miro-style frame resizing: drag the right / bottom edges or the corner. Handles
99
+ // live on the expanded card's drop zone (see template); the composable clamps to
100
+ // the frame's content extent and persists the size on release.
101
+ const { startResize } = useFrameResize()
102
+ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
103
+ if (block.value) startResize(block.value, e, edge)
104
+ }
105
+
106
+ function addTask() {
107
+ ui.expandFrame(props.id)
108
+ ui.openAddTask(props.id)
109
+ }
110
+
111
+ function addRecurring() {
112
+ ui.openAddRecurring(props.id)
113
+ }
114
+
115
+ // A task needs merging → green pulse; a task needs a decision → amber pulse.
116
+ const pulseClass = computed(() => {
117
+ if (frameStatus.value === 'blocked') return 'board-pulse'
118
+ if (prTasks.value > 0) return 'board-pulse-green'
119
+ return ''
120
+ })
121
+
122
+ // ---- agent-run overlay ------------------------------------------------------
123
+ // When this service frame was materialised by a "bootstrap repo" run, surface its
124
+ // live status + subtask progress on the card (the user watches the container adapt
125
+ // + push the repo), and the shared failure banner + retry if it faulted. Derived
126
+ // from the unified agentRuns store, keyed by this frame's block id.
127
+ const run = computed(() => agentRuns.byBlock[props.id])
128
+ const bootstrapping = computed(
129
+ () => run.value?.kind === 'bootstrap' && run.value.status === 'running',
130
+ )
131
+ const runFailed = computed(() => run.value?.status === 'failed')
132
+ const bootstrapSubtasks = computed(() =>
133
+ bootstrapping.value ? (run.value?.subtasks ?? null) : null,
134
+ )
135
+ const bootstrapPct = computed(() => {
136
+ const s = bootstrapSubtasks.value
137
+ if (!s || s.total <= 0) return 0
138
+ return Math.min(100, Math.round((s.completed / s.total) * 100))
139
+ })
140
+ // The actual todo items the agent is working through, surfaced on the expanded
141
+ // card so a zoomed-in user sees the task list, not just the "N/M" count.
142
+ const bootstrapItems = computed(() => bootstrapSubtasks.value?.items ?? [])
143
+ const ITEM_ICON: Record<string, string> = {
144
+ completed: 'i-lucide-check-circle-2',
145
+ in_progress: 'i-lucide-loader-circle',
146
+ pending: 'i-lucide-circle',
147
+ }
148
+ </script>
149
+
150
+ <template>
151
+ <div v-if="block" class="relative" :data-block-id="block.id">
152
+ <!-- decision / approval indicator floats above the card at all zoom levels -->
153
+ <div
154
+ v-if="blockDecisions.length || blockApprovals.length"
155
+ class="absolute -top-3 left-1/2 z-10 flex -translate-x-1/2 gap-1"
156
+ >
157
+ <DecisionBadge
158
+ v-if="blockDecisions.length"
159
+ :count="blockDecisions.length"
160
+ :compact="lod === 'far'"
161
+ @open="openFirstDecision"
162
+ />
163
+ <DecisionBadge
164
+ v-if="blockApprovals.length"
165
+ :count="blockApprovals.length"
166
+ :compact="lod === 'far'"
167
+ label="Approval needed"
168
+ icon="i-lucide-shield-check"
169
+ @open="openFirstApproval"
170
+ />
171
+ </div>
172
+
173
+ <!-- ===================== FAR: glanceable chip ===================== -->
174
+ <div
175
+ v-if="lod === 'far'"
176
+ class="flex w-44 items-center gap-2 rounded-xl border-2 px-3 py-3 shadow-lg backdrop-blur"
177
+ :class="[selected ? 'border-white' : '', pulseClass]"
178
+ :style="{ borderColor: accent, backgroundColor: accent + '26' }"
179
+ >
180
+ <span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: accent }" />
181
+ <span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
182
+ <UIcon
183
+ v-if="bootstrapping"
184
+ name="i-lucide-loader-circle"
185
+ class="ml-auto h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
186
+ title="Bootstrapping…"
187
+ />
188
+ <UIcon
189
+ v-else-if="runFailed"
190
+ name="i-lucide-alert-triangle"
191
+ class="ml-auto h-3.5 w-3.5 shrink-0 text-rose-400"
192
+ title="Run failed"
193
+ />
194
+ <span v-else-if="hasTasks" class="ml-auto shrink-0 text-[11px] text-slate-300">
195
+ {{ mergedTasks }}/{{ taskCount }}
196
+ </span>
197
+ </div>
198
+
199
+ <!-- ===================== COMPACT: summary (collapsed) ===================== -->
200
+ <div
201
+ v-else-if="!showExpanded"
202
+ class="w-56 overflow-hidden rounded-xl border bg-slate-900/90 shadow-xl backdrop-blur"
203
+ :class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
204
+ >
205
+ <div class="h-1.5 w-full" :style="{ backgroundColor: accent }" />
206
+ <!-- bootstrap-in-progress banner -->
207
+ <div v-if="bootstrapping" class="border-b border-amber-900/50 bg-amber-950/30 px-3 py-2">
208
+ <div class="flex items-center gap-1.5 text-[11px]">
209
+ <UIcon
210
+ name="i-lucide-loader-circle"
211
+ class="h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
212
+ />
213
+ <span class="text-amber-300">Bootstrapping…</span>
214
+ <span v-if="bootstrapSubtasks" class="ml-auto text-amber-200/80">
215
+ {{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }}
216
+ </span>
217
+ </div>
218
+ <div class="mt-1.5 h-1 w-full overflow-hidden rounded bg-amber-900/40">
219
+ <div
220
+ class="h-full rounded bg-amber-400 transition-all"
221
+ :style="{ width: bootstrapPct + '%' }"
222
+ />
223
+ </div>
224
+ <div v-if="run" class="mt-2 flex justify-end">
225
+ <AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
226
+ </div>
227
+ </div>
228
+ <!-- failed run: shared failure banner + retry -->
229
+ <div v-else-if="runFailed && run" class="p-2">
230
+ <AgentFailureCard :run="run" variant="compact" />
231
+ </div>
232
+ <div class="space-y-2 p-3">
233
+ <div class="flex items-center gap-2">
234
+ <UIcon
235
+ :name="typeMeta!.icon"
236
+ class="h-4 w-4 shrink-0"
237
+ :style="{ color: typeMeta!.accent }"
238
+ />
239
+ <span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
240
+ <UBadge
241
+ v-if="isShared"
242
+ color="info"
243
+ variant="subtle"
244
+ size="sm"
245
+ class="shrink-0"
246
+ title="Shared across workspaces in this org"
247
+ >
248
+ Shared
249
+ </UBadge>
250
+ </div>
251
+ <div class="flex items-center justify-between">
252
+ <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">{{
253
+ statusLabel
254
+ }}</UBadge>
255
+ <span class="text-[11px] text-slate-400"
256
+ >{{ taskCount }} task{{ taskCount === 1 ? '' : 's' }}</span
257
+ >
258
+ </div>
259
+ <button
260
+ type="button"
261
+ class="nodrag flex w-full items-center gap-1 rounded-md bg-slate-800/60 px-2 py-1 text-[10px] text-slate-300 hover:bg-slate-800"
262
+ @click.stop="toggleExpand"
263
+ >
264
+ <UIcon name="i-lucide-layers" class="h-3 w-3 text-slate-400" />
265
+ <span v-if="hasTasks">{{ mergedTasks }}/{{ taskCount }} merged</span>
266
+ <span v-else>No tasks yet</span>
267
+ <span v-if="prTasks" class="text-emerald-400">· {{ prTasks }} PR</span>
268
+ <UIcon name="i-lucide-chevron-down" class="ml-auto h-3 w-3" />
269
+ </button>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- ===================== EXPANDED: 2D canvas of tasks + modules ===================== -->
274
+ <div
275
+ v-else
276
+ class="overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
277
+ :class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
278
+ >
279
+ <div class="h-1.5 w-full rounded-t-2xl" :style="{ backgroundColor: accent }" />
280
+ <!-- bootstrap-in-progress banner -->
281
+ <div v-if="bootstrapping" class="border-b border-amber-900/50 bg-amber-950/30 px-4 py-2">
282
+ <div class="flex items-center gap-1.5 text-xs">
283
+ <UIcon
284
+ name="i-lucide-loader-circle"
285
+ class="h-4 w-4 shrink-0 animate-spin text-amber-400"
286
+ />
287
+ <span class="text-amber-300">Bootstrapping repository…</span>
288
+ <span v-if="bootstrapSubtasks" class="ml-auto text-amber-200/80">
289
+ {{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }} steps
290
+ </span>
291
+ </div>
292
+ <div class="mt-1.5 h-1 w-full overflow-hidden rounded bg-amber-900/40">
293
+ <div
294
+ class="h-full rounded bg-amber-400 transition-all"
295
+ :style="{ width: bootstrapPct + '%' }"
296
+ />
297
+ </div>
298
+ <!-- the actual todo list, once the agent has reported any items -->
299
+ <ul v-if="bootstrapItems.length" class="mt-2 space-y-1">
300
+ <li
301
+ v-for="(item, i) in bootstrapItems"
302
+ :key="i"
303
+ class="flex items-start gap-1.5 text-[11px]"
304
+ :class="
305
+ item.status === 'completed'
306
+ ? 'text-amber-200/60 line-through'
307
+ : item.status === 'in_progress'
308
+ ? 'text-amber-100'
309
+ : 'text-amber-200/80'
310
+ "
311
+ >
312
+ <UIcon
313
+ :name="ITEM_ICON[item.status]"
314
+ class="mt-px h-3 w-3 shrink-0"
315
+ :class="[
316
+ item.status === 'in_progress' ? 'animate-spin text-amber-400' : '',
317
+ item.status === 'completed' ? 'text-emerald-400' : 'text-amber-400/70',
318
+ ]"
319
+ />
320
+ <span>{{ item.label }}</span>
321
+ </li>
322
+ </ul>
323
+ <div v-if="run" class="mt-2 flex justify-end">
324
+ <AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
325
+ </div>
326
+ </div>
327
+ <!-- failed run: shared failure banner + retry -->
328
+ <div v-else-if="runFailed && run" class="p-3">
329
+ <AgentFailureCard :run="run" variant="expanded" />
330
+ </div>
331
+ <div class="space-y-3 p-4">
332
+ <!-- frame header (doubles as the drag handle for the expanded frame) -->
333
+ <div class="flex items-start justify-between gap-2">
334
+ <div
335
+ class="flex cursor-grab items-center gap-2 active:cursor-grabbing"
336
+ title="Drag service"
337
+ @pointerdown="onFrameHandle"
338
+ >
339
+ <div
340
+ class="flex h-8 w-8 items-center justify-center rounded-lg"
341
+ :style="{ backgroundColor: typeMeta!.accent + '22' }"
342
+ >
343
+ <UIcon :name="typeMeta!.icon" class="h-5 w-5" :style="{ color: typeMeta!.accent }" />
344
+ </div>
345
+ <div>
346
+ <div class="text-sm font-semibold text-white">{{ block.title }}</div>
347
+ <div class="text-[11px] text-slate-400">{{ typeMeta!.label }}</div>
348
+ </div>
349
+ </div>
350
+ <div class="flex items-center gap-1">
351
+ <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">{{
352
+ statusLabel
353
+ }}</UBadge>
354
+ <UButton
355
+ class="nodrag"
356
+ size="xs"
357
+ variant="ghost"
358
+ color="neutral"
359
+ icon="i-lucide-plus"
360
+ title="Add task"
361
+ @click.stop="addTask"
362
+ />
363
+ <UButton
364
+ class="nodrag"
365
+ size="xs"
366
+ variant="ghost"
367
+ color="neutral"
368
+ icon="i-lucide-repeat"
369
+ title="Add recurring pipeline"
370
+ @click.stop="addRecurring"
371
+ />
372
+ <UButton
373
+ class="nodrag"
374
+ size="xs"
375
+ variant="ghost"
376
+ color="neutral"
377
+ icon="i-lucide-chevron-up"
378
+ title="Collapse"
379
+ @click.stop="toggleExpand"
380
+ />
381
+ </div>
382
+ </div>
383
+
384
+ <div class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
385
+ <span>{{ mergedTasks }}/{{ taskCount }} implemented</span>
386
+ <span v-if="modules.length"
387
+ >· {{ modules.length }} module{{ modules.length === 1 ? '' : 's' }}</span
388
+ >
389
+ <span v-if="prTasks" class="text-emerald-400">· {{ prTasks }} PR ready</span>
390
+ </div>
391
+
392
+ <!-- the 2D drop zone: modules and loose tasks live here, draggable -->
393
+ <div
394
+ :data-drop-zone="block.id"
395
+ class="nodrag relative rounded-xl bg-slate-950/40"
396
+ :style="{ width: canvas.w + 'px', height: canvas.h + 'px' }"
397
+ >
398
+ <ModuleFrame v-for="m in modules" :key="m.id" :module-id="m.id" />
399
+ <DraggableTask v-for="t in directTasks" :key="t.id" :task-id="t.id" />
400
+ <button
401
+ v-if="!hasTasks"
402
+ type="button"
403
+ class="absolute inset-4 flex items-center justify-center gap-1 rounded-lg border border-dashed border-slate-700 text-[11px] text-slate-500 hover:border-slate-500 hover:text-slate-300"
404
+ @click.stop="addTask"
405
+ >
406
+ <UIcon name="i-lucide-plus" class="h-3.5 w-3.5" /> Add the first task
407
+ </button>
408
+
409
+ <!-- resize handles (drag the borders to resize the service, Miro-style) -->
410
+ <div
411
+ class="nodrag absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-sky-400/20"
412
+ title="Drag to resize"
413
+ @pointerdown="onResize($event, 'e')"
414
+ />
415
+ <div
416
+ class="nodrag absolute bottom-0 left-0 h-2 w-full cursor-ns-resize hover:bg-sky-400/20"
417
+ title="Drag to resize"
418
+ @pointerdown="onResize($event, 's')"
419
+ />
420
+ <div
421
+ class="nodrag absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize"
422
+ title="Drag to resize"
423
+ @pointerdown="onResize($event, 'se')"
424
+ >
425
+ <span
426
+ class="absolute bottom-1 right-1 h-2 w-2 rounded-sm border-b-2 border-r-2 border-slate-500"
427
+ />
428
+ </div>
429
+ </div>
430
+ </div>
431
+ </div>
432
+ </div>
433
+ </template>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ count?: number
5
+ compact?: boolean
6
+ /** Badge copy + glyph; defaults to the decision variant. */
7
+ label?: string
8
+ icon?: string
9
+ }>(),
10
+ { label: 'Decision needed', icon: 'i-lucide-circle-help' },
11
+ )
12
+ defineEmits<{ (e: 'open'): void }>()
13
+ </script>
14
+
15
+ <template>
16
+ <button
17
+ type="button"
18
+ class="board-pulse flex items-center gap-1 rounded-full bg-amber-500 px-2 py-0.5 text-xs font-semibold text-amber-950 shadow-lg transition hover:bg-amber-400"
19
+ @click.stop="$emit('open')"
20
+ >
21
+ <UIcon :name="icon" class="h-3.5 w-3.5" />
22
+ <span v-if="!compact">{{ label }}</span>
23
+ <span v-if="count && count > 1" class="rounded-full bg-amber-950/30 px-1">
24
+ {{ count }}
25
+ </span>
26
+ </button>
27
+ </template>
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import TaskCard from './TaskCard.vue'
3
+ import { useBlockDrag } from '~/composables/useBlockDrag'
4
+
5
+ const props = defineProps<{ taskId: string }>()
6
+ const board = useBoardStore()
7
+ const task = computed(() => board.getBlock(props.taskId))
8
+ const { draggingId, startDrag } = useBlockDrag()
9
+
10
+ // Once a task is merged it stops being a unit of work and becomes part of the
11
+ // architecture: it no longer renders as a draggable card (arrows fall back to its
12
+ // container), so we never leave a zero-size anchor behind.
13
+ const merged = computed(() => task.value?.status === 'done')
14
+
15
+ function onHandle(e: PointerEvent) {
16
+ if (task.value) startDrag(task.value, e, { reparent: true })
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <template v-if="task">
22
+ <!-- in-flight task → draggable work card (merged tasks render nothing) -->
23
+ <div
24
+ v-if="!merged"
25
+ class="absolute w-[180px]"
26
+ :style="{
27
+ left: task.position.x + 'px',
28
+ top: task.position.y + 'px',
29
+ zIndex: draggingId === taskId ? 60 : 10,
30
+ // While this task is being dragged it must not capture hit-tests, so the
31
+ // drop-zone (service or module) beneath the cursor can be resolved on
32
+ // release — including the drag handle, which lives in this wrapper above
33
+ // the card and would otherwise mask the zone under it.
34
+ pointerEvents: draggingId === taskId ? 'none' : undefined,
35
+ }"
36
+ >
37
+ <!-- drag handle -->
38
+ <div
39
+ class="nodrag flex cursor-grab items-center justify-center rounded-t-lg border border-b-0 border-slate-700 bg-slate-800/80 py-px active:cursor-grabbing"
40
+ title="Drag task"
41
+ @pointerdown="onHandle"
42
+ >
43
+ <UIcon name="i-lucide-grip-horizontal" class="h-3 w-3 text-slate-500" />
44
+ </div>
45
+ <TaskCard :task-id="taskId" class="!rounded-t-none" />
46
+ </div>
47
+ </template>
48
+ </template>
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ import DraggableTask from './DraggableTask.vue'
3
+ import { MODULE_META } from '~/utils/catalog'
4
+ import { useBlockDrag } from '~/composables/useBlockDrag'
5
+ import { useFrameResize } from '~/composables/useFrameResize'
6
+
7
+ const props = defineProps<{ moduleId: string }>()
8
+ const board = useBoardStore()
9
+ const ui = useUiStore()
10
+
11
+ const mod = computed(() => board.getBlock(props.moduleId))
12
+ const tasks = computed(() => board.tasksOf(props.moduleId))
13
+ const size = computed(() => board.containerSize(props.moduleId))
14
+ const selected = computed(() => ui.selectedBlockId === props.moduleId)
15
+
16
+ // A module groups the tasks inside it. We label it by how many tasks are still in
17
+ // flight, falling back to the total task count once everything inside has merged.
18
+ const inflight = computed(() => tasks.value.filter((t) => t.status !== 'done').length)
19
+ const total = computed(() => tasks.value.length)
20
+
21
+ const { draggingId, startDrag } = useBlockDrag()
22
+
23
+ // modules move within their service but don't get reparented
24
+ function onHandle(e: PointerEvent) {
25
+ if (mod.value) startDrag(mod.value, e)
26
+ }
27
+
28
+ // Miro-style resizing, same as a service frame: drag the right / bottom edges or
29
+ // the corner. The composable clamps to the module's content extent and persists
30
+ // the size on release.
31
+ const { startResize } = useFrameResize()
32
+ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
33
+ if (mod.value) startResize(mod.value, e, edge)
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <div
39
+ v-if="mod"
40
+ :data-block-id="mod.id"
41
+ class="absolute rounded-xl border border-violet-500/40 bg-violet-500/[0.06]"
42
+ :class="{ 'ring-1 ring-white': selected }"
43
+ :style="{
44
+ left: mod.position.x + 'px',
45
+ top: mod.position.y + 'px',
46
+ width: size.w + 'px',
47
+ height: size.h + 'px',
48
+ zIndex: draggingId === moduleId ? 50 : 5,
49
+ }"
50
+ >
51
+ <!-- module header / drag handle -->
52
+ <div
53
+ class="nodrag flex h-[30px] cursor-grab items-center gap-1 rounded-t-xl bg-violet-500/15 px-2 active:cursor-grabbing"
54
+ @pointerdown="onHandle"
55
+ @click.stop="ui.select(moduleId)"
56
+ >
57
+ <UIcon
58
+ :name="MODULE_META.icon"
59
+ class="h-3.5 w-3.5 shrink-0"
60
+ :style="{ color: MODULE_META.color }"
61
+ />
62
+ <span class="truncate text-[11px] font-semibold text-violet-100">{{ mod.title }}</span>
63
+ <span v-if="inflight" class="ml-auto shrink-0 text-[9px] text-violet-300/70">
64
+ {{ inflight }} task{{ inflight === 1 ? '' : 's' }}
65
+ </span>
66
+ <span v-else-if="total" class="ml-auto shrink-0 text-[9px] text-violet-300/70">
67
+ {{ total }} task{{ total === 1 ? '' : 's' }}
68
+ </span>
69
+ </div>
70
+
71
+ <!-- drop zone for this module's tasks -->
72
+ <div :data-drop-zone="mod.id" class="relative" :style="{ height: size.h - 30 + 'px' }">
73
+ <DraggableTask v-for="t in tasks" :key="t.id" :task-id="t.id" />
74
+ </div>
75
+
76
+ <!-- resize handles (drag the borders to resize the module, Miro-style) -->
77
+ <div
78
+ class="nodrag absolute right-0 top-0 h-full w-2 cursor-ew-resize hover:bg-violet-400/20"
79
+ title="Drag to resize"
80
+ @pointerdown="onResize($event, 'e')"
81
+ />
82
+ <div
83
+ class="nodrag absolute bottom-0 left-0 h-2 w-full cursor-ns-resize hover:bg-violet-400/20"
84
+ title="Drag to resize"
85
+ @pointerdown="onResize($event, 's')"
86
+ />
87
+ <div
88
+ class="nodrag absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize"
89
+ title="Drag to resize"
90
+ @pointerdown="onResize($event, 'se')"
91
+ >
92
+ <span
93
+ class="absolute bottom-1 right-1 h-2 w-2 rounded-sm border-b-2 border-r-2 border-violet-400/60"
94
+ />
95
+ </div>
96
+ </div>
97
+ </template>