@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,282 @@
1
+ <script setup lang="ts">
2
+ // Gate window — the dedicated surface for a polling gate step (`ci` / `conflicts`),
3
+ // opened via the universal result-view host (the same seam the test report and the
4
+ // requirements review use). It surfaces the gate's conclusion that the backend now
5
+ // persists on `step.gate`: the precheck verdict, the helper attempt budget, the gated
6
+ // commit, and — for CI — the failing checks behind the failure. One window serves both
7
+ // gates; it branches on the step's `agentKind` for the copy and the failure detail.
8
+ import { computed } from 'vue'
9
+ import { agentKindMeta } from '~/utils/catalog'
10
+ import type { GateStepState } from '~/types/execution'
11
+ import StepRestartControl from '~/components/panels/StepRestartControl.vue'
12
+
13
+ const board = useBoardStore()
14
+ const execution = useExecutionStore()
15
+
16
+ // Synchronous window: it reads its state straight off the execution step, so there's
17
+ // nothing to fetch on open (no `onOpen` loader).
18
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('gate')
19
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
20
+
21
+ const instance = computed(() =>
22
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
23
+ )
24
+ const step = computed(() => {
25
+ if (instance.value === null || stepIndex.value === null) return null
26
+ return instance.value.steps[stepIndex.value] ?? null
27
+ })
28
+ const gate = computed<GateStepState | null>(() => step.value?.gate ?? null)
29
+
30
+ const isCi = computed(() => step.value?.agentKind === 'ci')
31
+ const meta = computed(() => agentKindMeta(step.value?.agentKind ?? 'ci'))
32
+ const helperKind = computed(() => (isCi.value ? 'ci-fixer' : 'conflict-resolver'))
33
+ const helperMeta = computed(() => agentKindMeta(helperKind.value))
34
+
35
+ const failingChecks = computed(() => gate.value?.failingChecks ?? [])
36
+ const shortSha = computed(() => (gate.value?.headSha ? gate.value.headSha.slice(0, 7) : null))
37
+
38
+ /**
39
+ * The display status — a roll-up of the persisted gate state + the run's status, so the
40
+ * window reads as a conclusion rather than raw fields:
41
+ * - `passed` — the step finished (the precheck went green; the helper was never needed
42
+ * or fixed it);
43
+ * - `gave-up` — the run failed at this gate (attempt budget spent);
44
+ * - `fixing` — a helper agent is in flight on a failed precheck;
45
+ * - `failing` — the precheck failed and a helper is about to run;
46
+ * - `pending` — the provider is still computing;
47
+ * - `checking` — running the precheck.
48
+ */
49
+ type GateDisplayStatus = 'passed' | 'gave-up' | 'fixing' | 'failing' | 'pending' | 'checking'
50
+ const status = computed<GateDisplayStatus>(() => {
51
+ const s = step.value
52
+ if (!s) return 'checking'
53
+ if (s.state === 'done') return 'passed'
54
+ if (instance.value?.status === 'failed') return 'gave-up'
55
+ if (gate.value?.phase === 'working') return 'fixing'
56
+ if (gate.value?.lastVerdict === 'fail') return 'failing'
57
+ if (gate.value?.lastVerdict === 'pending') return 'pending'
58
+ return 'checking'
59
+ })
60
+
61
+ const STATUS_META: Record<
62
+ GateDisplayStatus,
63
+ { label: string; badge: 'success' | 'warning' | 'error' | 'neutral'; icon: string; text: string }
64
+ > = {
65
+ passed: {
66
+ label: 'Passed',
67
+ badge: 'success',
68
+ icon: 'i-lucide-circle-check',
69
+ text: 'text-emerald-300',
70
+ },
71
+ 'gave-up': { label: 'Gave up', badge: 'error', icon: 'i-lucide-circle-x', text: 'text-rose-300' },
72
+ fixing: { label: 'Fixing', badge: 'warning', icon: 'i-lucide-loader', text: 'text-amber-300' },
73
+ failing: {
74
+ label: 'Failing',
75
+ badge: 'error',
76
+ icon: 'i-lucide-circle-x',
77
+ text: 'text-rose-300',
78
+ },
79
+ pending: {
80
+ label: 'Pending',
81
+ badge: 'neutral',
82
+ icon: 'i-lucide-clock',
83
+ text: 'text-slate-300',
84
+ },
85
+ checking: {
86
+ label: 'Checking',
87
+ badge: 'neutral',
88
+ icon: 'i-lucide-loader',
89
+ text: 'text-slate-300',
90
+ },
91
+ }
92
+
93
+ // The conflicts gate has no structured detail (GitHub reports mergeability as a single
94
+ // verdict, no file list), so the window shows the verdict + a note rather than a list.
95
+ const conflictVerdict = computed(() => {
96
+ if (status.value === 'passed') return 'Mergeable'
97
+ if (gate.value?.lastVerdict === 'pending') return 'Computing mergeability…'
98
+ if (gate.value?.lastVerdict === 'fail') return 'Conflicts with base'
99
+ return 'Unknown'
100
+ })
101
+ </script>
102
+
103
+ <template>
104
+ <Teleport to="body">
105
+ <div
106
+ v-if="open"
107
+ class="fixed inset-0 z-50 flex items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
108
+ @click.self="close"
109
+ >
110
+ <div
111
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
112
+ >
113
+ <!-- Header -->
114
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
115
+ <span
116
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/15 text-sky-300"
117
+ >
118
+ <UIcon :name="meta.icon" class="h-4 w-4" />
119
+ </span>
120
+ <div class="min-w-0 flex-1">
121
+ <h2 class="truncate text-sm font-semibold text-slate-100">
122
+ {{ meta.label }}{{ block ? ` — ${block.title}` : '' }}
123
+ </h2>
124
+ <p class="truncate text-[11px] text-slate-400">
125
+ {{
126
+ isCi
127
+ ? 'Gates the PR on green CI, looping the CI fixer on failure'
128
+ : 'Gates the PR on a clean merge, looping the resolver on conflicts'
129
+ }}
130
+ </p>
131
+ </div>
132
+ <UBadge :color="STATUS_META[status].badge" variant="subtle" size="sm">
133
+ {{ STATUS_META[status].label }}
134
+ </UBadge>
135
+ <StepRestartControl
136
+ :instance-id="instanceId"
137
+ :step-index="stepIndex"
138
+ @restarted="close"
139
+ />
140
+ <button
141
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
142
+ @click="close"
143
+ >
144
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
145
+ </button>
146
+ </header>
147
+
148
+ <div class="flex min-h-0 flex-1">
149
+ <!-- Main: the conclusion -->
150
+ <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
151
+ <div
152
+ v-if="!gate"
153
+ class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
154
+ >
155
+ <UIcon :name="meta.icon" class="h-8 w-8 opacity-40" />
156
+ <p class="text-sm">No gate activity yet.</p>
157
+ <p class="max-w-sm text-[11px] text-slate-500">
158
+ The precheck runs once the PR is open. While it polls, the step shows live state on
159
+ the board.
160
+ </p>
161
+ </div>
162
+
163
+ <template v-else>
164
+ <!-- Passed -->
165
+ <div
166
+ v-if="status === 'passed'"
167
+ class="flex items-start gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5"
168
+ >
169
+ <UIcon
170
+ name="i-lucide-circle-check"
171
+ class="mt-0.5 h-4 w-4 shrink-0 text-emerald-400"
172
+ />
173
+ <p class="text-[13px] leading-relaxed text-emerald-200">
174
+ {{
175
+ step?.output || (isCi ? 'CI is green.' : 'The PR merges cleanly with its base.')
176
+ }}
177
+ </p>
178
+ </div>
179
+
180
+ <!-- CI: failing checks -->
181
+ <template v-else-if="isCi">
182
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
183
+ Failing checks
184
+ </h3>
185
+ <ul v-if="failingChecks.length" class="space-y-1">
186
+ <li
187
+ v-for="(c, i) in failingChecks"
188
+ :key="`${c.name}-${i}`"
189
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-1.5"
190
+ >
191
+ <UIcon name="i-lucide-circle-x" class="h-3.5 w-3.5 shrink-0 text-rose-400" />
192
+ <span class="min-w-0 flex-1 truncate text-[13px] text-slate-200">{{
193
+ c.name
194
+ }}</span>
195
+ <span class="shrink-0 text-[11px] uppercase text-rose-300">
196
+ {{ c.conclusion ?? 'failure' }}
197
+ </span>
198
+ </li>
199
+ </ul>
200
+ <p v-else class="text-[13px] leading-relaxed text-slate-300">
201
+ {{ gate.lastFailureSummary || 'CI has not reported a failure on this commit.' }}
202
+ </p>
203
+ </template>
204
+
205
+ <!-- Conflicts: verdict + note (no file-level detail from GitHub) -->
206
+ <template v-else>
207
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
208
+ Mergeability
209
+ </h3>
210
+ <div
211
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-950/40 px-3 py-2"
212
+ >
213
+ <UIcon
214
+ :name="STATUS_META[status].icon"
215
+ class="h-4 w-4 shrink-0"
216
+ :class="STATUS_META[status].text"
217
+ />
218
+ <span class="text-[13px] text-slate-200">{{ conflictVerdict }}</span>
219
+ </div>
220
+ <p class="mt-2 text-[11px] leading-relaxed text-slate-500">
221
+ GitHub reports mergeability as a single verdict, so there's no file-level conflict
222
+ list here. The conflict resolver inspects the branch directly.
223
+ </p>
224
+ </template>
225
+ </template>
226
+ </div>
227
+
228
+ <!-- Sidebar: gate state -->
229
+ <aside
230
+ class="hidden w-56 shrink-0 flex-col gap-4 border-l border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
231
+ >
232
+ <div v-if="gate">
233
+ <h4 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
234
+ State
235
+ </h4>
236
+ <div class="flex items-center gap-2 text-[13px]">
237
+ <UIcon
238
+ :name="STATUS_META[status].icon"
239
+ class="h-4 w-4"
240
+ :class="STATUS_META[status].text"
241
+ />
242
+ <span :class="STATUS_META[status].text">{{ STATUS_META[status].label }}</span>
243
+ </div>
244
+ </div>
245
+
246
+ <div v-if="gate">
247
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
248
+ {{ helperMeta.label }}
249
+ </h4>
250
+ <p class="text-[12px] text-slate-300">
251
+ {{ gate.attempts }}/{{ gate.maxAttempts }} attempt{{
252
+ gate.maxAttempts === 1 ? '' : 's'
253
+ }}
254
+ <template v-if="gate.phase === 'working'"> · running…</template>
255
+ <template v-else-if="gate.attempts === 0"> · not needed yet</template>
256
+ </p>
257
+ </div>
258
+
259
+ <div v-if="shortSha">
260
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
261
+ Gated commit
262
+ </h4>
263
+ <p class="font-mono text-[12px] text-slate-300">{{ shortSha }}</p>
264
+ </div>
265
+
266
+ <div v-if="step?.model">
267
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
268
+ Model
269
+ </h4>
270
+ <p class="break-all text-[12px] text-slate-300">{{ step.model }}</p>
271
+ </div>
272
+
273
+ <p class="mt-auto text-[10px] leading-relaxed text-slate-600">
274
+ A gate runs a programmatic precheck and only spins up the
275
+ {{ helperMeta.label }} when it fails — a green check advances with nothing spun up.
276
+ </p>
277
+ </aside>
278
+ </div>
279
+ </div>
280
+ </div>
281
+ </Teleport>
282
+ </template>
@@ -0,0 +1,354 @@
1
+ <script setup lang="ts">
2
+ // Add a board service backed by an EXISTING GitHub repository — no bootstrap
3
+ // run. Unlike the bootstrap modal (which creates a repo and has an agent adapt
4
+ // it in a container), this just links a repo the App can access to a fresh,
5
+ // `ready` service frame. The workspace need not track the repo yet: the backend
6
+ // links + syncs it on import. If the App can't see the wanted repo, the user
7
+ // grants it access from here, then refreshes the list.
8
+ //
9
+ // MONOREPO support: a repo flagged a monorepo can back SEVERAL services, each
10
+ // pinned to a subdirectory. When the selected repo is a monorepo, the user
11
+ // browses its tree and picks the service's directory before adding (and may add
12
+ // more than one, a subset of the repo's services).
13
+ import GitHubConnect from '~/components/github/GitHubConnect.vue'
14
+ import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
15
+ import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
16
+ import ServiceFragments from '~/components/panels/inspector/ServiceFragments.vue'
17
+
18
+ const ui = useUiStore()
19
+ const github = useGitHubStore()
20
+ const board = useBoardStore()
21
+ const toast = useToast()
22
+
23
+ const open = computed({
24
+ get: () => ui.addServiceOpen,
25
+ set: (v: boolean) => {
26
+ if (!v) ui.closeAddService()
27
+ },
28
+ })
29
+
30
+ const selectedRepoId = ref<number | undefined>(undefined)
31
+ const adding = ref(false)
32
+
33
+ async function loadRepos() {
34
+ try {
35
+ await github.probe()
36
+ if (github.connected) await Promise.all([github.load(), github.loadAvailableRepos()])
37
+ } catch {
38
+ // Integration off / unreachable → the picker stays empty, GitHubConnect shows.
39
+ }
40
+ }
41
+
42
+ // On open: ensure we know the connection + which repos the App can access, and
43
+ // the workspace's already-tracked repos (to flag ones already on the board).
44
+ watch(open, (isOpen) => {
45
+ if (!isOpen) return
46
+ resetSelection()
47
+ void loadRepos()
48
+ })
49
+
50
+ // If the user connects from inside the modal (the not-connected prompt), pull the
51
+ // repo list as soon as the connection is bound.
52
+ watch(
53
+ () => github.connected,
54
+ (isConnected) => {
55
+ if (isConnected && open.value) void loadRepos()
56
+ },
57
+ )
58
+
59
+ // The integration is on but this workspace isn't bound yet — connect first.
60
+ const needsGitHub = computed(() => github.available === true && !github.connected)
61
+
62
+ // Repos already backing a board service can't be added again — UNLESS they're a
63
+ // monorepo, which can host several services (each at its own subdirectory).
64
+ const onBoardIds = computed(
65
+ () => new Set(github.repos.filter((r) => r.blockId).map((r) => r.githubId)),
66
+ )
67
+
68
+ const repoItems = computed(() =>
69
+ github.availableRepos.map((r) => {
70
+ const onBoard = onBoardIds.value.has(r.githubId) && !r.isMonorepo
71
+ const mono = r.isMonorepo ? ' · monorepo' : ''
72
+ return {
73
+ label: `${r.owner}/${r.name}${r.private ? ' (private)' : ''}${mono}${onBoard ? ' · already on board' : ''}`,
74
+ // Searched on (lowercased once) — the owner/name, so the filter matches either.
75
+ search: `${r.owner}/${r.name}`.toLowerCase(),
76
+ value: r.githubId,
77
+ disabled: onBoard,
78
+ }
79
+ }),
80
+ )
81
+
82
+ // The PAT (or a wide App install) can expose hundreds of repos, too many for a plain
83
+ // dropdown — filter by owner/name. The currently selected repo is always kept in the
84
+ // list so a selection doesn't vanish when the query no longer matches it.
85
+ const repoSearch = ref('')
86
+ const filteredRepoItems = computed(() => {
87
+ const q = repoSearch.value.trim().toLowerCase()
88
+ if (!q) return repoItems.value
89
+ return repoItems.value.filter((r) => r.search.includes(q) || r.value === selectedRepoId.value)
90
+ })
91
+
92
+ const hasRepos = computed(() => github.availableRepos.length > 0)
93
+ const selectedRepo = computed(() =>
94
+ github.availableRepos.find((r) => r.githubId === selectedRepoId.value),
95
+ )
96
+
97
+ // ---- monorepo flag + directory picker ------------------------------------
98
+
99
+ // The monorepo flag is MODAL-LOCAL state, sent as part of the add-service request
100
+ // rather than persisted up-front on a toggle: there's no need to round-trip a PATCH
101
+ // before adding (browsing the tree needs only the repo id, and the backend flags the
102
+ // repo + requires a directory when it creates the service). A repo already flagged a
103
+ // monorepo (it backs other services) seeds the toggle on when selected.
104
+ const isMonorepo = ref(false)
105
+ const selectedDirectory = ref<string | undefined>(undefined)
106
+
107
+ function toggleMonorepo(value: boolean) {
108
+ isMonorepo.value = value
109
+ selectedDirectory.value = undefined
110
+ }
111
+
112
+ // On repo change, seed the toggle from the repo's persisted flag and clear the rest.
113
+ watch(selectedRepoId, () => {
114
+ isMonorepo.value = selectedRepo.value?.isMonorepo === true
115
+ selectedDirectory.value = undefined
116
+ configuredBlockId.value = undefined
117
+ })
118
+
119
+ function resetSelection() {
120
+ selectedRepoId.value = undefined
121
+ selectedDirectory.value = undefined
122
+ isMonorepo.value = false
123
+ configuredBlockId.value = undefined
124
+ repoSearch.value = ''
125
+ }
126
+
127
+ // The App's installation settings page — where the user grants it access to a
128
+ // repo it can't see yet (mirrors the bootstrap modal's "grant access" link).
129
+ const manageInstallUrl = computed(() => {
130
+ const conn = github.connection
131
+ if (!conn) return undefined
132
+ return conn.targetType === 'Organization'
133
+ ? `https://github.com/organizations/${conn.accountLogin}/settings/installations/${conn.installationId}`
134
+ : `https://github.com/settings/installations/${conn.installationId}`
135
+ })
136
+
137
+ function openManageInstall() {
138
+ if (manageInstallUrl.value) window.open(manageInstallUrl.value, '_blank', 'noopener')
139
+ }
140
+
141
+ // The just-added service, kept on the board store so the user can configure it (test
142
+ // infra + fragments) right here — the same controls as the inspector. A monorepo can
143
+ // host several services, so adding another keeps the modal open; a whole-repo service
144
+ // can only be added once (its repo is then on the board).
145
+ const configuredBlockId = ref<string | undefined>(undefined)
146
+ const configuredDirectory = ref<string | undefined>(undefined)
147
+ const configuredBlock = computed(() =>
148
+ configuredBlockId.value ? board.getBlock(configuredBlockId.value) : undefined,
149
+ )
150
+
151
+ // A monorepo service needs a chosen directory; a whole-repo service can be added once.
152
+ const canAdd = computed(
153
+ () =>
154
+ !needsGitHub.value &&
155
+ selectedRepoId.value !== undefined &&
156
+ (isMonorepo.value ? !!selectedDirectory.value : !configuredBlockId.value),
157
+ )
158
+
159
+ async function add() {
160
+ if (!canAdd.value || selectedRepoId.value === undefined) return
161
+ adding.value = true
162
+ try {
163
+ const block = await board.addServiceFromRepo(selectedRepoId.value, {
164
+ directory: isMonorepo.value ? selectedDirectory.value : undefined,
165
+ isMonorepo: isMonorepo.value,
166
+ })
167
+ // Refresh the projection so the new repo↔block link is reflected locally.
168
+ await github.load()
169
+ configuredBlockId.value = block.id
170
+ configuredDirectory.value = isMonorepo.value ? selectedDirectory.value : undefined
171
+ toast.add({
172
+ title: 'Service added',
173
+ description: `${block.title} is on the board — configure it below.`,
174
+ icon: 'i-lucide-check',
175
+ color: 'success',
176
+ })
177
+ // Ready to pick another monorepo service (the just-added directory is taken).
178
+ selectedDirectory.value = undefined
179
+ } catch (e) {
180
+ toast.add({
181
+ title: 'Could not add service',
182
+ description: e instanceof Error ? e.message : String(e),
183
+ icon: 'i-lucide-triangle-alert',
184
+ color: 'error',
185
+ })
186
+ } finally {
187
+ adding.value = false
188
+ }
189
+ }
190
+
191
+ function done() {
192
+ ui.closeAddService()
193
+ }
194
+ </script>
195
+
196
+ <template>
197
+ <UModal v-model:open="open" title="Add a service from a repository" :ui="{ content: 'max-w-xl' }">
198
+ <template #body>
199
+ <div class="space-y-6">
200
+ <p class="text-sm text-slate-400">
201
+ Pick an existing GitHub repository to add as a board service. No bootstrapping — the repo
202
+ is linked to a new service frame as-is, and tasks you run on it target that repo.
203
+ </p>
204
+
205
+ <!-- not connected: linking a repo needs the App bound to this workspace -->
206
+ <div
207
+ v-if="needsGitHub"
208
+ class="space-y-3 rounded-md border border-amber-500/30 bg-amber-500/5 p-3"
209
+ >
210
+ <div class="flex items-start gap-2">
211
+ <UIcon name="i-lucide-plug-zap" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
212
+ <p class="text-sm text-amber-200/90">
213
+ Connect this workspace to GitHub first. Link an installation the App is already on, or
214
+ install it.
215
+ </p>
216
+ </div>
217
+ <GitHubConnect />
218
+ </div>
219
+
220
+ <template v-else>
221
+ <UFormField
222
+ label="Repository"
223
+ description="Repositories the GitHub App can access. Don't see yours? Grant the App access below, then refresh."
224
+ required
225
+ >
226
+ <div v-if="!hasRepos" class="text-sm text-slate-400">
227
+ No repositories available yet — grant the App access to one below, then refresh.
228
+ </div>
229
+ <div v-else class="space-y-1.5">
230
+ <UInput
231
+ v-model="repoSearch"
232
+ icon="i-lucide-search"
233
+ placeholder="Filter by owner/name…"
234
+ class="w-full"
235
+ :ui="{ trailing: 'pe-1' }"
236
+ >
237
+ <template v-if="repoSearch" #trailing>
238
+ <UButton
239
+ color="neutral"
240
+ variant="link"
241
+ size="sm"
242
+ icon="i-lucide-x"
243
+ aria-label="Clear filter"
244
+ @click="repoSearch = ''"
245
+ />
246
+ </template>
247
+ </UInput>
248
+ <USelect
249
+ v-model="selectedRepoId"
250
+ :items="filteredRepoItems"
251
+ placeholder="Choose a repository"
252
+ class="w-full"
253
+ />
254
+ <p class="text-xs text-slate-500">
255
+ Showing {{ filteredRepoItems.length }} of {{ repoItems.length }} repositories.
256
+ </p>
257
+ </div>
258
+ </UFormField>
259
+
260
+ <!-- monorepo handling: flag + directory picker -->
261
+ <div v-if="selectedRepoId !== undefined" class="space-y-3">
262
+ <USwitch
263
+ :model-value="isMonorepo"
264
+ label="This is a monorepo (hosts more than one service)"
265
+ description="Add several services from one repo, each pinned to a subdirectory."
266
+ @update:model-value="toggleMonorepo"
267
+ />
268
+
269
+ <div
270
+ v-if="isMonorepo"
271
+ class="rounded-md border border-slate-700/60 bg-slate-900/40 p-3"
272
+ >
273
+ <p class="mb-2 text-xs text-slate-400">
274
+ Browse the repository and pick the directory of the service you want to add. Agents
275
+ working on this service will run within that subdirectory.
276
+ </p>
277
+ <RepoTreeBrowser
278
+ v-model="selectedDirectory"
279
+ :repo-github-id="selectedRepoId!"
280
+ mode="dir"
281
+ />
282
+ <p class="mt-2 truncate text-xs text-slate-400">
283
+ <template v-if="selectedDirectory">
284
+ Service directory:
285
+ <code class="text-slate-200">{{ selectedDirectory }}</code>
286
+ </template>
287
+ <template v-else>No directory selected yet.</template>
288
+ </p>
289
+ </div>
290
+ </div>
291
+
292
+ <!-- just-added service: configure it with the same controls as the inspector -->
293
+ <div
294
+ v-if="configuredBlock"
295
+ class="space-y-4 rounded-md border border-emerald-900/50 bg-emerald-950/20 p-3"
296
+ >
297
+ <div
298
+ class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-400"
299
+ >
300
+ <UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
301
+ {{ configuredBlock.title }} added — configure it
302
+ </div>
303
+ <ServiceTestConfig
304
+ :block="configuredBlock"
305
+ :repo="{ githubId: selectedRepoId!, directory: configuredDirectory }"
306
+ />
307
+ <ServiceFragments :block="configuredBlock" />
308
+ </div>
309
+
310
+ <div class="flex flex-wrap items-center gap-2">
311
+ <UButton
312
+ v-if="manageInstallUrl"
313
+ color="neutral"
314
+ variant="subtle"
315
+ size="sm"
316
+ icon="i-lucide-shield-check"
317
+ trailing-icon="i-lucide-external-link"
318
+ title="Open the App's installation settings to grant it access to a repository"
319
+ @click="openManageInstall"
320
+ >
321
+ Grant the App access to a repo
322
+ </UButton>
323
+ <UButton
324
+ color="neutral"
325
+ variant="ghost"
326
+ size="sm"
327
+ icon="i-lucide-refresh-cw"
328
+ :loading="github.loadingAvailable"
329
+ @click="github.loadAvailableRepos()"
330
+ >
331
+ Refresh list
332
+ </UButton>
333
+ </div>
334
+
335
+ <div class="flex justify-end gap-2">
336
+ <UButton v-if="configuredBlock" color="neutral" variant="soft" size="sm" @click="done">
337
+ Done
338
+ </UButton>
339
+ <UButton
340
+ v-if="!configuredBlock || isMonorepo"
341
+ color="primary"
342
+ icon="i-lucide-plus"
343
+ :loading="adding"
344
+ :disabled="!canAdd"
345
+ @click="add"
346
+ >
347
+ {{ configuredBlock && isMonorepo ? 'Add another service' : 'Add service' }}
348
+ </UButton>
349
+ </div>
350
+ </template>
351
+ </div>
352
+ </template>
353
+ </UModal>
354
+ </template>