@cat-factory/app 0.259.3 → 0.261.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 (34) hide show
  1. package/README.md +34 -0
  2. package/app/components/auth/LoginScreen.vue +4 -3
  3. package/app/components/board/BoardCanvas.vue +9 -1
  4. package/app/components/board/TaskDependencyEdges.vue +35 -26
  5. package/app/components/github/AddServiceFromRepoModal.vue +132 -29
  6. package/app/components/settings/McpAuthorizeScreen.vue +262 -0
  7. package/app/composables/api/mcpAuthorization.ts +30 -0
  8. package/app/composables/useApi.ts +2 -0
  9. package/app/composables/useBoardActivity.ts +111 -0
  10. package/app/composables/useSettlingRaf.ts +32 -0
  11. package/app/composables/useTaskExpansion.ts +29 -11
  12. package/app/pages/mcp-authorize.vue +7 -0
  13. package/app/stores/auth/session.ts +14 -2
  14. package/app/stores/board/placement.ts +12 -3
  15. package/app/stores/board.spec.ts +22 -2
  16. package/app/utils/edgeSegments.spec.ts +49 -0
  17. package/app/utils/edgeSegments.ts +49 -0
  18. package/app/utils/monorepoImport.spec.ts +120 -0
  19. package/app/utils/monorepoImport.ts +154 -0
  20. package/app/utils/postSignIn.spec.ts +29 -0
  21. package/app/utils/postSignIn.ts +29 -0
  22. package/app/utils/settlingLoop.spec.ts +236 -0
  23. package/app/utils/settlingLoop.ts +101 -0
  24. package/i18n/locales/de.json +45 -1
  25. package/i18n/locales/en.json +45 -1
  26. package/i18n/locales/es.json +45 -1
  27. package/i18n/locales/fr.json +45 -1
  28. package/i18n/locales/he.json +45 -1
  29. package/i18n/locales/it.json +45 -1
  30. package/i18n/locales/ja.json +45 -1
  31. package/i18n/locales/pl.json +45 -1
  32. package/i18n/locales/tr.json +45 -1
  33. package/i18n/locales/uk.json +45 -1
  34. package/package.json +2 -2
package/README.md CHANGED
@@ -68,6 +68,40 @@ Order within the column is by what the user loses by not reading it now; the too
68
68
 
69
69
  `app/components/layout/BoardTopOverlays.spec.ts` enforces the no-self-placement half, reading the member list from the component's own imports.
70
70
 
71
+ ### A board driver that MEASURES the DOM runs off the activity pulse, never a bare RAF
72
+
73
+ Two board features cannot be derived from the stores alone: the dependency-edge overlay needs
74
+ each card's on-screen rectangle, and the task-expansion driver needs the topmost card under the
75
+ pointer. Both used `useRafFn`, so an open board paid O(edges) `querySelector` plus forced layout
76
+ reads sixty times a second with nothing moving, and the edge overlay reassigned an
77
+ equal-but-new segment array every frame on top of that.
78
+
79
+ **A new driver of that kind pairs `useSettlingRaf(compute)` with the canvas pulse
80
+ (`useBoardActivity`), and `compute` reports honestly whether it changed anything.** The pulse
81
+ answers "something may have started moving" (DOM mutations under the canvas, its resize, the
82
+ Vue Flow camera, pointer/wheel/scroll gestures) and the settling loop carries that wake through
83
+ the animation that follows, parking once the output has held still for a few frames. Neither
84
+ half works alone: a signal fires one frame BEFORE the transition it starts has any geometry, and
85
+ a bare frame loop never stops.
86
+
87
+ Two things this cost, both worth knowing before adding a third driver. `compute` returning
88
+ `true` unconditionally silently restores the old behaviour, which is why the loop's contract is
89
+ stated in terms of what the user can see rather than what the function did. And the pulse
90
+ watches `style`/`class` attributes but not the geometry attributes the overlay itself writes,
91
+ because a driver whose own output pulsed it awake would never settle.
92
+
93
+ A `compute` that THROWS parks the loop and lets the error reach the frame callback, so the next
94
+ pulse of any kind is what restarts it. Retrying the frame instead would turn one bad measurement
95
+ into a 60Hz error storm, and staying awake with no frame scheduled would make every later poke a
96
+ no-op and freeze the board for the session.
97
+
98
+ What the pulse cannot see is a reflow with no mutation and no gesture, a late-loading image or
99
+ font resizing a card. That leaves an arrow stale until the next pulse of any kind, which is the
100
+ deliberate trade: firing too often costs a handful of frames, and the alternative is the loop
101
+ that never sleeps.
102
+
103
+ `app/utils/settlingLoop.spec.ts` pins the loop against a hand-driven frame clock.
104
+
71
105
  ### A store must be instantiable outside a component `setup`
72
106
 
73
107
  A Pinia setup store runs its body on the FIRST `useStore()` anywhere in the app, and that
@@ -5,6 +5,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
5
5
  import type { VcsProvider } from '~/types/domain'
6
6
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS, vcsTokenCreateUrl } from '~/utils/vcs'
7
7
  import { SSO_ERROR_MESSAGE_KEYS } from '~/utils/sso'
8
+ import { postSignInUrl } from '~/utils/postSignIn'
8
9
 
9
10
  const auth = useAuthStore()
10
11
  const { t } = useI18n()
@@ -62,7 +63,7 @@ async function submitPat(provider: PatProvider) {
62
63
  patBusy.value = true
63
64
  try {
64
65
  await auth.patLogin({ provider })
65
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
66
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
66
67
  } catch (e) {
67
68
  patError.value = apiErrorEnvelope(e)?.message ?? t('auth.localMode.failed')
68
69
  } finally {
@@ -105,7 +106,7 @@ async function submitPassword() {
105
106
  await auth.passwordLogin({ email: email.value, password: password.value })
106
107
  }
107
108
  // Reload so the app boots with the new session.
108
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
109
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
109
110
  } catch (e) {
110
111
  error.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
111
112
  } finally {
@@ -177,7 +178,7 @@ async function submitRemotePat() {
177
178
  remotePatBusy.value = true
178
179
  try {
179
180
  await auth.patLogin({ provider: remotePatProvider.value, token: remotePatToken.value.trim() })
180
- if (typeof window !== 'undefined') window.location.assign(window.location.pathname)
181
+ if (typeof window !== 'undefined') window.location.assign(postSignInUrl(window.location))
181
182
  } catch (e) {
182
183
  remotePatError.value = apiErrorEnvelope(e)?.message ?? t('auth.login.signInFailed')
183
184
  } finally {
@@ -7,6 +7,7 @@ import TaskDependencyEdges from './TaskDependencyEdges.vue'
7
7
  import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
8
8
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
9
9
  import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
10
+ import { provideBoardActivity } from '~/composables/useBoardActivity'
10
11
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
11
12
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
13
  import { useFrameStacking } from '~/composables/useFrameStacking'
@@ -46,7 +47,10 @@ const panOnDrag = computed<boolean | number[]>(() => boardPanMode(hasTouch.value
46
47
  // centre-most of any that would overlap (see useTaskExpansion). Service frames have no
47
48
  // such gate — they are always expanded to their task canvas.
48
49
  const boardEl = ref<HTMLElement | null>(null)
49
- useTaskExpansion(boardEl)
50
+ // The canvas owns the "something may have moved" pulse both DOM-measuring drivers run off:
51
+ // this one directly, the dependency-edge overlay by injection. See useBoardActivity.
52
+ const boardActivity = provideBoardActivity(boardEl)
53
+ useTaskExpansion(boardEl, boardActivity)
50
54
 
51
55
  // Only frames are board nodes. Dependencies live on tasks (rendered inside the
52
56
  // frames), so there are no frame-to-frame edges on the canvas.
@@ -100,6 +104,10 @@ onNodeDragStop(({ node }) => {
100
104
 
101
105
  onViewportChange((vp) => {
102
106
  ui.zoom = vp.zoom
107
+ // Pan and zoom move every card on screen. Vue Flow does that by restyling its transform
108
+ // pane, which the pulse's observer would also catch, but the camera is too load-bearing for
109
+ // the overlays to depend on which DOM strategy Vue Flow uses to apply it.
110
+ boardActivity.pulse()
103
111
  })
104
112
 
105
113
  function onNodeClick({ node }: NodeMouseEvent) {
@@ -1,29 +1,31 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
3
- import { useRafFn } from '@vueuse/core'
2
+ import { ref, shallowRef, computed, watch } from 'vue'
3
+ import { useBoardActivity } from '~/composables/useBoardActivity'
4
+ import { useSettlingRaf } from '~/composables/useSettlingRaf'
5
+ import { commitSegments, type EdgeSegment } from '~/utils/edgeSegments'
4
6
 
5
7
  /**
6
8
  * Draws dependency arrows between task cards as an SVG overlay on top of the
7
9
  * board. Tasks are plain DOM nodes (inside frame cards), so we resolve their
8
- * on-screen rectangles by `[data-block-id]` every frame — this makes arrows
9
- * follow pan / zoom / drag / expand for free. When a task's frame is collapsed
10
- * (its card isn't rendered), the arrow anchors to the frame card instead.
10
+ * on-screen rectangles by `[data-block-id]` — this makes arrows follow pan /
11
+ * zoom / drag / expand for free. When a task's frame is collapsed (its card
12
+ * isn't rendered), the arrow anchors to the frame card instead.
13
+ *
14
+ * Measuring is O(edges) `querySelector` + forced layout reads, so it runs only
15
+ * while something is actually moving: the board's activity pulse wakes it and
16
+ * `useSettlingRaf` parks it again once the resolved segments hold still.
11
17
  */
12
18
  const board = useBoardStore()
13
19
 
14
20
  const svg = ref<SVGSVGElement | null>(null)
15
21
 
16
- type Seg = { id: string; x1: number; y1: number; x2: number; y2: number; done: boolean }
17
- const segments = ref<Seg[]>([])
22
+ const segments = shallowRef<EdgeSegment[]>([])
18
23
  // Epic→member membership links (distinct style from dependency edges).
19
- type MemberSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
20
- const memberSegments = ref<MemberSeg[]>([])
24
+ const memberSegments = shallowRef<EdgeSegment[]>([])
21
25
  // Frontend frame → bound service frame links (from a frontend's backend bindings).
22
- type FrontendSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
23
- const frontendSegments = ref<FrontendSeg[]>([])
26
+ const frontendSegments = shallowRef<EdgeSegment[]>([])
24
27
  // Service frame → connected provider service frame links (from serviceConnections).
25
- type ConnectionSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
26
- const connectionSegments = ref<ConnectionSeg[]>([])
28
+ const connectionSegments = shallowRef<EdgeSegment[]>([])
27
29
 
28
30
  // task → its dependencies, both ends being tasks
29
31
  const taskDeps = computed(() => {
@@ -129,8 +131,8 @@ function segmentBetween(sourceId: string, targetId: string, origin: DOMRect) {
129
131
  function linkSegments(
130
132
  links: { id: string; source: string; target: string }[],
131
133
  origin: DOMRect,
132
- ): { id: string; x1: number; y1: number; x2: number; y2: number }[] {
133
- const out: { id: string; x1: number; y1: number; x2: number; y2: number }[] = []
134
+ ): EdgeSegment[] {
135
+ const out: EdgeSegment[] = []
134
136
  for (const link of links) {
135
137
  const seg = segmentBetween(link.source, link.target, origin)
136
138
  if (seg) out.push({ id: link.id, ...seg })
@@ -138,27 +140,34 @@ function linkSegments(
138
140
  return out
139
141
  }
140
142
 
141
- function recompute() {
143
+ /** Re-measure every overlay link; reports whether any of them moved. */
144
+ function recompute(): boolean {
142
145
  const el = svg.value
143
- if (!el) return
146
+ if (!el) return false
144
147
  const origin = el.getBoundingClientRect()
145
148
 
146
- const next: Seg[] = []
149
+ const deps: EdgeSegment[] = []
147
150
  for (const d of taskDeps.value) {
148
151
  const seg = segmentBetween(d.source, d.target, origin)
149
152
  if (!seg) continue
150
- next.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
153
+ deps.push({ id: d.id, ...seg, done: board.getBlock(d.source)?.status === 'done' })
151
154
  }
152
- segments.value = next
153
155
 
154
- memberSegments.value = linkSegments(epicLinks.value, origin)
155
- frontendSegments.value = linkSegments(frontendLinks.value, origin)
156
- connectionSegments.value = linkSegments(connectionLinks.value, origin)
156
+ // An array literal, so every list is committed before the result is reduced: a `||` chain
157
+ // would short-circuit and leave the later overlays drawn at stale coordinates.
158
+ return [
159
+ commitSegments(segments, deps),
160
+ commitSegments(memberSegments, linkSegments(epicLinks.value, origin)),
161
+ commitSegments(frontendSegments, linkSegments(frontendLinks.value, origin)),
162
+ commitSegments(connectionSegments, linkSegments(connectionLinks.value, origin)),
163
+ ].some(Boolean)
157
164
  }
158
165
 
159
- const { pause, resume } = useRafFn(recompute, { immediate: false })
160
- onMounted(resume)
161
- onBeforeUnmount(pause)
166
+ const { poke } = useSettlingRaf(recompute)
167
+ useBoardActivity(poke)
168
+ // A link set can change with no visible change to any card (toggling a dependency between two
169
+ // tasks draws an arrow and nothing else), which the DOM-level pulse would never see.
170
+ watch([taskDeps, epicLinks, frontendLinks, connectionLinks], poke)
162
171
  </script>
163
172
 
164
173
  <template>
@@ -12,7 +12,15 @@
12
12
  // browses its tree and multi-selects the service directories to add — from ANY
13
13
  // parent folder, in one pass — then adds them all at once. Directories that
14
14
  // already back a service on this board are shown but not selectable.
15
+ //
16
+ // One of those directories may be marked the FRONTEND for the rest: it is created as a
17
+ // `frontend` frame instead of a service, pinned to its subdirectory, and bound to every
18
+ // backend added beside it (`frontendConfig.backendBindings`, the frontend→service board
19
+ // link). Every frontend frame the import creates also gets its subdirectory recorded on
20
+ // `frontendConfig`, marked or not. The rules live in `~/utils/monorepoImport`, which also
21
+ // explains why the bindings carry no env-var names.
15
22
  import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
23
+ import type { CreatedMonorepoFrame } from '~/utils/monorepoImport'
16
24
  import RepoSearchEmpty from '~/components/github/RepoSearchEmpty.vue'
17
25
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
18
26
  import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
@@ -170,9 +178,53 @@ const addedDirectories = computed<string[]>(() => {
170
178
  })
171
179
  const addedDirSet = computed(() => new Set(addedDirectories.value))
172
180
 
181
+ // What the next "Add N services" will actually create: the cart minus anything already backing a
182
+ // service. THE population every frontend-mark decision reads, and the one `addServices` iterates.
183
+ // The two can differ: a partial failure leaves the cart intact while its earlier creates stand, so
184
+ // judging the mark by the raw cart would offer it (and count it) for frames that already exist.
185
+ const pendingDirectories = computed(() =>
186
+ selectedDirectories.value.filter((d) => !addedDirSet.value.has(normalizeRepoPath(d))),
187
+ )
188
+
189
+ // The one picked directory marked as the frontend for the others, or undefined when the
190
+ // selection is all backends. Empty string is the select's "none" option.
191
+ const frontendDirectory = ref<string | undefined>(undefined)
192
+
193
+ // Whether the mark is on offer at all (role must be `service`, at least two directories to
194
+ // create): see `canDesignateFrontend`. The picker is hidden otherwise, so drop a mark that a role
195
+ // change has made unofferable rather than leaving it to act unseen.
196
+ const frontendOffered = computed(() =>
197
+ canDesignateFrontend(selectedType.value, pendingDirectories.value.length),
198
+ )
199
+ watch(frontendOffered, (offered) => {
200
+ if (!offered) frontendDirectory.value = undefined
201
+ })
202
+ // The pending set is the option list, so a directory that leaves it (removed from the cart, or
203
+ // created by an earlier add) can no longer be the mark. The computed re-runs on the cart's
204
+ // in-place mutations (`push`/`splice`), so no deep watch is needed on top of it.
205
+ watch(pendingDirectories, (dirs) => {
206
+ if (frontendDirectory.value && !dirs.includes(frontendDirectory.value)) {
207
+ frontendDirectory.value = undefined
208
+ }
209
+ })
210
+
211
+ const frontendItems = computed(() => [
212
+ { label: t('github.addService.frontendNone'), value: '' },
213
+ ...pendingDirectories.value.map((d) => ({ label: d, value: d })),
214
+ ])
215
+
216
+ // USelect needs a present value for its "none" row; the mark itself stays absent-or-a-path.
217
+ const frontendSelection = computed({
218
+ get: () => frontendDirectory.value ?? '',
219
+ set: (value: string) => {
220
+ frontendDirectory.value = value || undefined
221
+ },
222
+ })
223
+
173
224
  function toggleMonorepo(value: boolean) {
174
225
  isMonorepo.value = value
175
226
  selectedDirectories.value = []
227
+ frontendDirectory.value = undefined
176
228
  }
177
229
 
178
230
  // Add/remove a directory from the cart. Guards against an already-added directory (the
@@ -209,12 +261,14 @@ watch(selectedRepoId, (id) => {
209
261
  }
210
262
  isMonorepo.value = selectedRepo.value?.isMonorepo === true
211
263
  selectedDirectories.value = []
264
+ frontendDirectory.value = undefined
212
265
  configuredBlockId.value = undefined
213
266
  })
214
267
 
215
268
  function resetSelection() {
216
269
  selectedRepoId.value = undefined
217
270
  selectedDirectories.value = []
271
+ frontendDirectory.value = undefined
218
272
  isMonorepo.value = false
219
273
  configuredBlockId.value = undefined
220
274
  resetRepoSearch()
@@ -265,18 +319,13 @@ const canAddServices = computed(
265
319
  !needsConnection.value &&
266
320
  selectedRepoId.value !== undefined &&
267
321
  isMonorepo.value &&
268
- selectedDirectories.value.length > 0,
322
+ pendingDirectories.value.length > 0,
269
323
  )
270
324
 
271
325
  // Directories the user has picked but NOT yet committed via "Add N services". Closing the
272
326
  // modal ("Done") would silently discard them — almost never what the user wants — so the
273
- // footer's Done is disabled while any remain (see the template). Filtered against the
274
- // already-added set for parity with `addServices`, so a stale cart entry can't block Done.
275
- const hasPendingSelection = computed(
276
- () =>
277
- isMonorepo.value &&
278
- selectedDirectories.value.some((d) => !addedDirSet.value.has(normalizeRepoPath(d))),
279
- )
327
+ // footer's Done is disabled while any remain (see the template).
328
+ const hasPendingSelection = computed(() => isMonorepo.value && pendingDirectories.value.length > 0)
280
329
 
281
330
  async function add() {
282
331
  if (!canAdd.value || selectedRepoId.value === undefined) return
@@ -315,38 +364,71 @@ async function add() {
315
364
  }
316
365
  }
317
366
 
318
- // Add every directory in the cart as its own service, in one action. Each add lays the
319
- // frame out in free space (seeing the ones added earlier in the loop, so they don't
320
- // overlap); the projection is refreshed and the camera centres on the last one. The
321
- // just-added directories then move to `addedDirectories`, so the cart is cleared and the
322
- // tree marks them "added" ready to pick more (from any folder) or close.
367
+ // What the success toast says about the frontend wiring, which is the half of the add that can
368
+ // fail on its own. A landed mark names the directory and points at the inspector for the env-var
369
+ // names the import deliberately leaves empty; a patch that did not persist says SO, because the
370
+ // frames are on the board either way and a silent omission reads exactly like a clean import. The
371
+ // failure note covers an undesignated frontend frame too: it lost its subdirectory, so the harness
372
+ // would build the repo root.
373
+ function frontendNote(designatedDirectory: string | undefined, wiringLanded: boolean): string {
374
+ if (!wiringLanded) return t('github.addService.toast.frontendWiringFailedNote')
375
+ if (!designatedDirectory) return ''
376
+ return t('github.addService.toast.frontendLinkedNote', { directory: designatedDirectory })
377
+ }
378
+
379
+ // Add every pending directory as its own frame, in one action. Each add lays the frame out in free
380
+ // space (seeing the ones added earlier in the loop, so they don't overlap); the projection is
381
+ // refreshed and the camera centres on the last one. The just-added directories then move to
382
+ // `addedDirectories`, so the cart is cleared and the tree marks them "added", ready to pick more
383
+ // (from any folder) or close. That is why the pending set is SNAPSHOTTED before the first await:
384
+ // each create refreshes the projection, so the live computed shrinks under the loop.
385
+ //
386
+ // A created `frontend` frame is then patched with its `frontendConfig`: its subdirectory always,
387
+ // plus a binding per sibling frame when it is the marked one. Those patches can only run after the
388
+ // loop, because the bindings name block ids the creates mint. The frame being wired is the one the
389
+ // PLAN designated, never whichever entry happens to carry `type: 'frontend'` (see
390
+ // `MonorepoImportEntry.designatedFrontend`). A patch that does not land leaves its frames standing
391
+ // and is REPORTED: `updateBlock` toasts its own failure and answers whether it persisted, so the
392
+ // success toast claims only the links that were actually written.
323
393
  async function addServices() {
324
394
  if (!canAddServices.value || selectedRepoId.value === undefined) return
325
- const dirs = selectedDirectories.value.filter((d) => !addedDirSet.value.has(normalizeRepoPath(d)))
395
+ const dirs = [...pendingDirectories.value]
326
396
  if (dirs.length === 0) return
397
+ // The mark is handed over raw: `planMonorepoImport` applies `canDesignateFrontend` itself over
398
+ // the very directories it is creating, so there is no second copy of that condition to drift.
399
+ const plan = planMonorepoImport(dirs, selectedType.value, frontendDirectory.value)
400
+ const designatedDirectory = plan.find((entry) => entry.designatedFrontend)?.directory
327
401
  adding.value = true
328
402
  try {
329
- let lastBlock: Awaited<ReturnType<typeof board.addServiceFromRepo>> | undefined
330
- for (const directory of dirs) {
331
- lastBlock = await board.addServiceFromRepo(selectedRepoId.value, {
332
- directory,
403
+ const created: CreatedMonorepoFrame[] = []
404
+ for (const entry of plan) {
405
+ const block = await board.addServiceFromRepo(selectedRepoId.value, {
406
+ directory: entry.directory,
333
407
  isMonorepo: true,
334
- type: selectedType.value,
408
+ type: entry.type,
335
409
  position: freeFramePosition(),
336
410
  })
411
+ created.push({ blockId: block.id, entry })
412
+ }
413
+ let wiringLanded = true
414
+ for (const patch of planFrontendConfigPatches(created)) {
415
+ const persisted = await board.updateBlock(patch.blockId, { frontendConfig: patch.config })
416
+ if (!persisted) wiringLanded = false
337
417
  }
338
418
  await github.load()
339
- if (lastBlock) await focusFrame(lastBlock.id)
419
+ const lastBlockId = created.at(-1)?.blockId
420
+ if (lastBlockId) await focusFrame(lastBlockId)
340
421
  selectedDirectories.value = []
341
422
  toast.add({
342
423
  title: t('github.addService.toast.servicesAddedTitle'),
343
- description: t(
344
- 'github.addService.toast.servicesAddedDescription',
345
- { count: dirs.length },
346
- dirs.length,
347
- ),
348
- icon: 'i-lucide-check',
349
- color: 'success',
424
+ description: [
425
+ t('github.addService.toast.servicesAddedDescription', { count: dirs.length }, dirs.length),
426
+ frontendNote(designatedDirectory, wiringLanded),
427
+ ]
428
+ .filter(Boolean)
429
+ .join(' '),
430
+ icon: wiringLanded ? 'i-lucide-check' : 'i-lucide-triangle-alert',
431
+ color: wiringLanded ? 'success' : 'warning',
350
432
  })
351
433
  } catch (e) {
352
434
  toast.add({
@@ -498,6 +580,25 @@ function done() {
498
580
  <p v-else class="text-xs text-slate-500">
499
581
  {{ t('github.addService.noServicesSelected') }}
500
582
  </p>
583
+
584
+ <!-- Mark one pick as the frontend for the others: it is created as a frontend
585
+ app and bound to every backend added beside it. Only offered while the
586
+ mark would wire something (see `canDesignateFrontend`). -->
587
+ <UFormField
588
+ v-if="frontendOffered"
589
+ :label="t('github.addService.frontendLabel')"
590
+ :description="t('github.addService.frontendHint')"
591
+ >
592
+ <USelect
593
+ v-model="frontendSelection"
594
+ :items="frontendItems"
595
+ value-key="value"
596
+ size="sm"
597
+ class="w-full"
598
+ data-testid="add-service-frontend-select"
599
+ />
600
+ </UFormField>
601
+
501
602
  <div class="flex justify-end">
502
603
  <UButton
503
604
  color="primary"
@@ -507,11 +608,13 @@ function done() {
507
608
  :disabled="!canAddServices"
508
609
  @click="addServices"
509
610
  >
611
+ <!-- Counts what the click will CREATE, not the raw cart: an entry whose frame
612
+ already exists (a retry after a partial failure) is not added again. -->
510
613
  {{
511
614
  t(
512
615
  'github.addService.addServices',
513
- { count: selectedDirectories.length },
514
- selectedDirectories.length,
616
+ { count: pendingDirectories.length },
617
+ pendingDirectories.length,
515
618
  )
516
619
  }}
517
620
  </UButton>