@cat-factory/app 0.248.0 → 0.249.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 (33) hide show
  1. package/app/components/board/AddTaskModal.vue +1 -0
  2. package/app/components/board/nodes/BlockNode.vue +24 -0
  3. package/app/components/context/ContextAttachmentFields.vue +102 -0
  4. package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
  5. package/app/components/context/pastedLinkOffer.logic.ts +50 -0
  6. package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
  7. package/app/components/documents/SpawnPreviewModal.vue +92 -16
  8. package/app/components/documents/StartFromDesignModal.vue +237 -0
  9. package/app/components/layout/AccountDeploymentSettings.vue +122 -0
  10. package/app/composables/api/documents.ts +17 -6
  11. package/app/composables/useDocumentSourceConnect.ts +88 -0
  12. package/app/modular/external-tools.spec.ts +1 -0
  13. package/app/modular/nav-contributions.spec.ts +2 -0
  14. package/app/modular/nav-contributions.ts +10 -0
  15. package/app/modular/nav-gates.ts +4 -0
  16. package/app/modular/registry.spec.ts +1 -0
  17. package/app/modular/tutorial-tours.spec.ts +5 -0
  18. package/app/modular/tutorial-tours.ts +77 -0
  19. package/app/pages/index.vue +4 -0
  20. package/app/stores/documents.spec.ts +60 -0
  21. package/app/stores/documents.ts +52 -24
  22. package/app/stores/ui/modals.ts +15 -0
  23. package/i18n/locales/de.json +82 -6
  24. package/i18n/locales/en.json +82 -6
  25. package/i18n/locales/es.json +82 -6
  26. package/i18n/locales/fr.json +82 -6
  27. package/i18n/locales/he.json +82 -6
  28. package/i18n/locales/it.json +82 -6
  29. package/i18n/locales/ja.json +82 -6
  30. package/i18n/locales/pl.json +82 -6
  31. package/i18n/locales/tr.json +82 -6
  32. package/i18n/locales/uk.json +82 -6
  33. package/package.json +2 -2
@@ -1230,6 +1230,7 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
1230
1230
  v-if="ui.addTaskContainerId"
1231
1231
  v-model="pendingContext"
1232
1232
  :scope-block-id="ui.addTaskContainerId"
1233
+ :description="description"
1233
1234
  :docs-hint="t('board.addTask.noDocsHint')"
1234
1235
  :issues-hint="t('board.addTask.noIssuesHint')"
1235
1236
  />
@@ -20,6 +20,7 @@ const board = useBoardStore()
20
20
  const execution = useExecutionStore()
21
21
  const ui = useUiStore()
22
22
  const tasks = useTasksStore()
23
+ const documents = useDocumentsStore()
23
24
  const agentRuns = useAgentRunsStore()
24
25
  const services = useServicesStore()
25
26
  const reviews = useReviewStage()
@@ -161,6 +162,18 @@ function addRecurring() {
161
162
  ui.openAddRecurring(props.id)
162
163
  }
163
164
 
165
+ /**
166
+ * Start a task from a design link, scoped to THIS service.
167
+ *
168
+ * BASIC tier, unlike the recurring/initiative buttons beside it: for a design-led team this is
169
+ * the everyday delivery loop rather than planning about it, which is the tier bar. It is offered
170
+ * only where a design source is actually connected, so a board that has none carries no control
171
+ * whose first click is a dead end.
172
+ */
173
+ function startFromDesign() {
174
+ ui.openStartFromDesign(props.id)
175
+ }
176
+
164
177
  // Hunt this service's tracker board for a bug worth picking up. Scoped to THIS frame, so
165
178
  // an adopted candidate lands here rather than wherever the board's first frame happens to be.
166
179
  function huntBugs() {
@@ -410,6 +423,17 @@ const ITEM_ICON: Record<string, string> = {
410
423
  :title="t('board.frame.createTaskFromIssueTitle')"
411
424
  @click.stop="createTaskFromIssue"
412
425
  />
426
+ <UButton
427
+ v-if="documents.connectedDesignSources.length > 0"
428
+ class="nodrag"
429
+ data-testid="frame-start-from-design"
430
+ :size="isTouch ? 'sm' : 'xs'"
431
+ variant="ghost"
432
+ color="neutral"
433
+ icon="i-lucide-frame"
434
+ :title="t('board.frame.startFromDesignTitle')"
435
+ @click.stop="startFromDesign"
436
+ />
413
437
  <!-- Recurring pipelines + initiatives are ADVANCED-tier authoring: both plan
414
438
  work rather than do it (a schedule that fires runs on a cadence, an
415
439
  initiative that groups tasks under a goal), and the basic frame header is
@@ -14,7 +14,10 @@
14
14
  // root-mounted with independent open flags), so in-progress form data survives. That upgrade is
15
15
  // itself gated on `integrations.manage` for documents, whose ATTACH writes are member-tier while
16
16
  // storing the credential is not.
17
+ import { refDebounced } from '@vueuse/core'
18
+ import type { DocumentSourceKind } from '~/types/domain'
17
19
  import type { PendingContext } from '~/composables/useContextLinking'
20
+ import { claimCandidates, firstLinkCandidate } from '~/components/context/pastedLinkOffer.logic'
18
21
  import { connectableSources } from '~/utils/sourcePicker'
19
22
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
20
23
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
@@ -32,6 +35,11 @@ const props = defineProps<{
32
35
  * service's linked repo, so there is no unscoped mode to fall back on.
33
36
  */
34
37
  scopeBlockId: string
38
+ /**
39
+ * The description the author is writing, watched for a pasted link worth attaching. Optional:
40
+ * a host with no description field simply never offers.
41
+ */
42
+ description?: string
35
43
  }>()
36
44
 
37
45
  const emit = defineEmits<{ 'update:modelValue': [PendingContext[]] }>()
@@ -89,6 +97,80 @@ const chosenDocKeys = computed(() => pendingDocs.value.map(contextKey))
89
97
  const showIssuePicker = ref(false)
90
98
  const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
91
99
 
100
+ // ---- the pasted-link offer ------------------------------------------------------------------
101
+ // A URL named in a description already reaches the run path, where an UNIMPORTED one is dropped
102
+ // with an info line and the agent gets nothing from it. Offering to attach it here turns that
103
+ // silent drop into a decision the author can still make. Only HOST-PINNED sources are asked
104
+ // (`claimCandidates`), because a host-blind parser claims a shape rather than a reference.
105
+ const offer = ref<{ url: string; source: DocumentSourceKind; externalId: string } | null>(null)
106
+ /** The URL last resolved, so re-typing around an unchanged link costs no further round trips. */
107
+ let judged: string | null = null
108
+ /**
109
+ * Which link-resolution pass is the current one. A pass that has been superseded neither writes an
110
+ * offer nor spends another request.
111
+ *
112
+ * `judged` alone cannot do this: it is set BEFORE the awaits, so it stops a second pass starting
113
+ * for the same URL but says nothing about a pass already in flight for a DIFFERENT one. The
114
+ * description is a text field a person is still typing in, and the debounce releases a new pass
115
+ * every 500ms, so an earlier URL's resolve routinely settles after a later one. Landing it would
116
+ * offer to attach a link that is no longer in the description at all, and accepting the offer
117
+ * attaches that wrong document, silently: the chip it adds names the stale URL, which is the only
118
+ * place the mismatch is visible and the one part nobody re-reads.
119
+ */
120
+ let offerPass = 0
121
+
122
+ // Debounced, because this fires on every keystroke of a description and each miss costs one
123
+ // request per connected host-pinned source.
124
+ const debouncedDescription = refDebounced(
125
+ computed(() => props.description ?? ''),
126
+ 500,
127
+ )
128
+
129
+ watch(
130
+ debouncedDescription,
131
+ async (text) => {
132
+ const url = firstLinkCandidate(text)
133
+ if (url === judged) return
134
+ const pass = ++offerPass
135
+ judged = url
136
+ offer.value = null
137
+ if (!url || !documents.available) return
138
+ for (const source of claimCandidates(documents.connectedSources.map((s) => s.source))) {
139
+ // Checked per source, not just around the write: once superseded, the remaining sources
140
+ // would be asked about a URL nobody is looking at any more.
141
+ if (pass !== offerPass) return
142
+ try {
143
+ const ref = await documents.resolveRef(source, url)
144
+ if (pass !== offerPass) return
145
+ offer.value = { url, source, externalId: ref.externalId }
146
+ return
147
+ } catch {
148
+ // silent-catch-ok: a source that refuses (or cannot be reached) simply makes no offer.
149
+ // There is nothing to report — the paste stays in the description exactly as typed, and
150
+ // the attach picker above is the route for anything this could not judge.
151
+ }
152
+ }
153
+ },
154
+ { immediate: true },
155
+ )
156
+
157
+ /** Stage the offered link, then withdraw the offer: it is now a chip in the list below. */
158
+ function acceptOffer() {
159
+ const accepted = offer.value
160
+ if (!accepted) return
161
+ const descriptor = documents.descriptorFor(accepted.source)
162
+ addPending({
163
+ kind: 'document',
164
+ source: accepted.source,
165
+ externalId: accepted.externalId,
166
+ title: accepted.url,
167
+ subtitle: descriptor?.label,
168
+ icon: descriptor?.icon,
169
+ needsImport: true,
170
+ })
171
+ offer.value = null
172
+ }
173
+
92
174
  function addPending(item: PendingContext) {
93
175
  if (props.modelValue.some((c) => contextKey(c) === contextKey(item))) return
94
176
  emit('update:modelValue', [...props.modelValue, item])
@@ -160,6 +242,26 @@ function removePending(item: PendingContext) {
160
242
  {{ t('contextAttachments.attach') }}
161
243
  </UButton>
162
244
  </div>
245
+ <!-- Offered, never applied: the author wrote the link into prose, and silently turning
246
+ that into an attachment would attach pages nobody meant to attach. -->
247
+ <div
248
+ v-if="offer && docsConnected"
249
+ class="flex items-center gap-2 rounded-md border border-indigo-900/60 bg-indigo-950/30 px-2 py-1.5"
250
+ data-testid="pasted-link-offer"
251
+ >
252
+ <UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
253
+ <span class="min-w-0 flex-1 truncate text-xs text-slate-300">
254
+ {{
255
+ t('contextAttachments.pastedLink.offer', {
256
+ source: documents.descriptorFor(offer.source)?.label ?? offer.source,
257
+ })
258
+ }}
259
+ </span>
260
+ <UButton color="primary" variant="soft" size="xs" @click="acceptOffer">
261
+ {{ t('contextAttachments.pastedLink.attach') }}
262
+ </UButton>
263
+ </div>
264
+
163
265
  <ContextDocumentPicker
164
266
  v-if="showDocPicker && docsConnected"
165
267
  :chosen-keys="chosenDocKeys"
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { claimCandidates, firstLinkCandidate } from './pastedLinkOffer.logic'
3
+
4
+ describe('firstLinkCandidate', () => {
5
+ it('picks a URL out of prose and drops the punctuation the sentence left on it', () => {
6
+ expect(
7
+ firstLinkCandidate('Build the screen at https://www.figma.com/design/abc?node-id=1-2.'),
8
+ ).toBe('https://www.figma.com/design/abc?node-id=1-2')
9
+ expect(firstLinkCandidate('see [design](https://www.figma.com/design/abc), then ship')).toBe(
10
+ 'https://www.figma.com/design/abc',
11
+ )
12
+ })
13
+
14
+ it('answers null for text with no link, and takes only the FIRST of several', () => {
15
+ expect(firstLinkCandidate('make the button green')).toBeNull()
16
+ // One chip beside a field cannot answer "which of these did you mean", and guessing is
17
+ // worse than sending the author to the attach picker, which is still right there.
18
+ expect(firstLinkCandidate('https://a.test/one and https://b.test/two')).toBe(
19
+ 'https://a.test/one',
20
+ )
21
+ })
22
+ })
23
+
24
+ describe('claimCandidates', () => {
25
+ it('asks only HOST-PINNED sources, whatever else is connected', () => {
26
+ // Notion's parser claims any UUID-shaped run, so asked about a Figma link whose file key
27
+ // happens to carry one it answers yes — and the offer would stage a design into Notion's key
28
+ // space. Registration order is preserved for the ones that remain.
29
+ expect(claimCandidates(['notion', 'figma', 'confluence', 'zeplin'])).toEqual([
30
+ 'figma',
31
+ 'zeplin',
32
+ ])
33
+ expect(claimCandidates(['notion', 'confluence'])).toEqual([])
34
+ })
35
+ })
@@ -0,0 +1,50 @@
1
+ import { isHostPinnedSource } from '@cat-factory/contracts'
2
+ import type { DocumentSourceKind } from '~/types/domain'
3
+
4
+ // The pure half of "you pasted a link into the description; want it attached?".
5
+ //
6
+ // A URL named in a task description already reaches the run path, where an UNIMPORTED one is
7
+ // dropped with an info line and the agent gets no context from it. This offer is what turns that
8
+ // drop into an attachment while the author is still on the form. What makes it safe to offer at
9
+ // all is the same rule the canonicaliser and the refusal-claimant search follow: only a
10
+ // HOST-PINNED source may claim a URL, because a host-blind parser claims a SHAPE and will happily
11
+ // claim a dashboard link that has nothing to do with it.
12
+
13
+ /** How much text is scanned for a link. Bounded: this runs as someone types a description. */
14
+ const MAX_SCANNED_CHARS = 4000
15
+
16
+ /**
17
+ * Trailing characters a URL written in prose collects, stripped so the offer resolves the link
18
+ * rather than the sentence it sits in. Brackets and quotes come in Markdown link syntax.
19
+ */
20
+ const TRAILING_NOISE = /[)\]}>.,;:!?'"]+$/
21
+
22
+ /**
23
+ * The first `http(s)` URL in `text`, or null.
24
+ *
25
+ * FIRST rather than all of them: the offer is one chip beside a form field, and a description
26
+ * naming five links needs the attach picker, not five chips. Someone who pasted the design link
27
+ * second can still attach it there, so nothing is lost by declining to guess which of many was
28
+ * meant.
29
+ */
30
+ export function firstLinkCandidate(text: string): string | null {
31
+ const match = /https?:\/\/[^\s<>"']+/i.exec(text.slice(0, MAX_SCANNED_CHARS))
32
+ if (!match) return null
33
+ const trimmed = match[0].replace(TRAILING_NOISE, '')
34
+ return trimmed.length > 0 ? trimmed : null
35
+ }
36
+
37
+ /**
38
+ * The sources worth ASKING about a pasted URL: connected, and host-pinned.
39
+ *
40
+ * Host-pinned is not an optimisation here, it is the whole safety property. Asking Notion (whose
41
+ * parser claims any UUID-shaped run) about a Figma link gets a confident yes, and the offer would
42
+ * then stage a design against Notion's key space — the exact mis-attribution
43
+ * `orderSourcesByClaimConfidence` exists to prevent on every other surface. Sources arrive in
44
+ * registry order and stay in it, so two pinned sources are asked in a stable order.
45
+ */
46
+ export function claimCandidates(
47
+ connected: readonly DocumentSourceKind[],
48
+ ): readonly DocumentSourceKind[] {
49
+ return connected.filter(isHostPinnedSource)
50
+ }
@@ -4,6 +4,13 @@
4
4
  // same modal serves Confluence, Notion and any future source. Secret credentials
5
5
  // are write-only — the backend never returns them, so on reload we show
6
6
  // "Connected" with empty fields.
7
+ //
8
+ // A source that declares an OAuth half AND has a registered app on this deployment leads with
9
+ // "Connect with <source>", and the credential form moves BELOW it as the fallback. That order is
10
+ // the point of the slice: minting a personal access token by hand is an unreasonable first step
11
+ // to put in front of a designer, and whichever affordance comes first is the one people use.
12
+ // Where no app is registered the button is absent entirely rather than disabled, because the
13
+ // remedy belongs to an admin in a different surface and a dead control states nothing.
7
14
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
8
15
  import SecretInput from '~/components/common/SecretInput.vue'
9
16
 
@@ -33,6 +40,33 @@ const back = useIntegrationBack(open)
33
40
  /** One value per credential field, reset whenever the modal (re)opens. */
34
41
  const values = ref<Record<string, string>>({})
35
42
  const saving = ref(false)
43
+ const startingOAuth = ref(false)
44
+
45
+ /** The OAuth half is offered only when the SOURCE declares one and this deployment registered an app. */
46
+ const oauth = computed(() =>
47
+ source.value && descriptor.value?.oauth && documents.canConnectWithOAuth(source.value)
48
+ ? descriptor.value.oauth
49
+ : null,
50
+ )
51
+
52
+ async function connectWithOAuth() {
53
+ if (!source.value) return
54
+ startingOAuth.value = true
55
+ try {
56
+ // Navigates away on success, so nothing after this runs; the `finally` covers the refusal
57
+ // path (an app un-registered between the probe and the click).
58
+ await documents.beginOAuthConnect(source.value)
59
+ } catch (e) {
60
+ toast.add({
61
+ title: t('documents.connect.oauth.failed', { source: descriptor.value?.label ?? '' }),
62
+ description: e instanceof Error ? e.message : String(e),
63
+ icon: 'i-lucide-triangle-alert',
64
+ color: 'error',
65
+ })
66
+ } finally {
67
+ startingOAuth.value = false
68
+ }
69
+ }
36
70
 
37
71
  watch(open, (isOpen) => {
38
72
  if (isOpen) values.value = {}
@@ -97,6 +131,22 @@ async function disconnect() {
97
131
  {{ t('documents.connect.intro', { source: descriptor.label }) }}
98
132
  </p>
99
133
 
134
+ <div v-if="oauth" class="space-y-2">
135
+ <UButton
136
+ color="primary"
137
+ icon="i-lucide-shield-check"
138
+ :loading="startingOAuth"
139
+ data-testid="document-connect-oauth"
140
+ @click="connectWithOAuth"
141
+ >
142
+ {{ t('documents.connect.oauth.action', { source: descriptor.label }) }}
143
+ </UButton>
144
+ <p class="text-[11px] text-slate-500">
145
+ {{ t('documents.connect.oauth.scopes', { scopes: oauth.scopes.join(', ') }) }}
146
+ </p>
147
+ <p class="text-[11px] text-slate-500">{{ t('documents.connect.oauth.fallback') }}</p>
148
+ </div>
149
+
100
150
  <div class="space-y-3">
101
151
  <UFormField
102
152
  v-for="field in descriptor.credentialFields"
@@ -1,21 +1,30 @@
1
1
  <script setup lang="ts">
2
+ import { isDesignSource } from '@cat-factory/contracts'
2
3
  import type { DocumentBoardPlan } from '~/types/domain'
3
4
 
4
5
  // Preview the structure an imported document expands into, then spawn it. The
5
6
  // plan is fetched fresh on open; a badge makes clear whether an LLM or the
6
7
  // deterministic heading parser produced it.
7
8
  //
8
- // A spawn always creates new top-level frames. The planner's job is to decompose a
9
- // document into services, so spawning into an existing frame could only flatten the
10
- // planned frames into it silently discarding the frame titles and types rendered
11
- // below, i.e. producing something other than what this preview showed. Scoping a
12
- // spawn to one service needs a target-aware PLAN (a prompt that yields modules and
13
- // tasks for an existing service), not a target-aware write.
9
+ // A PROSE document spawns new top-level frames: the planner's job there is to decompose a
10
+ // specification into services. A DESIGN document cannot answer that question it describes
11
+ // screens, and asked for an architecture it produces a service per Figma page so it is planned
12
+ // INTO a service that already exists, and the frame picker below is required before a plan is
13
+ // even requested. The frame is then sent to the spawn as well, so the write re-plans against the
14
+ // same target and nothing the preview showed is discarded.
14
15
  const { t } = useI18n()
15
16
  const ui = useUiStore()
17
+ const board = useBoardStore()
16
18
  const documents = useDocumentsStore()
17
19
  const toast = useToast()
18
20
 
21
+ /** Design documents are planned into an existing service; prose documents at the board root. */
22
+ const needsTarget = computed(() => !!ui.spawnPreview && isDesignSource(ui.spawnPreview.source))
23
+ const targetFrameId = ref<string | undefined>()
24
+ const frameOptions = computed(() =>
25
+ board.frames.map((frame) => ({ label: frame.title, value: frame.id })),
26
+ )
27
+
19
28
  const open = computed({
20
29
  get: () => ui.spawnPreview !== null,
21
30
  set: (v: boolean) => {
@@ -27,16 +36,50 @@ const plan = ref<DocumentBoardPlan | null>(null)
27
36
  const loadingPlan = ref(false)
28
37
  const spawning = ref(false)
29
38
 
39
+ // Declared BEFORE the planning watcher so it runs first: a target picked for the previous
40
+ // document must not survive into the next one, where it would silently plan a different design
41
+ // into a service nobody chose for it.
30
42
  watch(
31
43
  () => ui.spawnPreview?.externalId,
32
- async (externalId) => {
44
+ () => {
45
+ targetFrameId.value = undefined
46
+ },
47
+ )
48
+
49
+ /**
50
+ * Which re-plan is the current one. Each request supersedes the one before it, and a superseded
51
+ * response is DROPPED rather than rendered.
52
+ *
53
+ * Two requests are genuinely in flight whenever the target is switched while a plan is still
54
+ * loading, and an LLM plan takes long enough for that to be ordinary rather than a stress case.
55
+ * The responses can land in either order, so without this the OLDER frame's plan can overwrite the
56
+ * newer one and sit under a selection it was not authored for: the preview then disagrees with the
57
+ * write, which re-plans against the target the user actually picked. That disagreement is the one
58
+ * thing the target-aware path exists to prevent, and it is silent, because a plan for the wrong
59
+ * service looks exactly like a plan.
60
+ */
61
+ let planRequest = 0
62
+
63
+ // The preview re-plans whenever the TARGET moves, not only when the document does: a plan
64
+ // authored for one service says nothing about another, and leaving the previous frame's modules
65
+ // on screen under a newly-picked service is the misreading this whole variant exists to prevent.
66
+ watch(
67
+ [() => ui.spawnPreview?.externalId, targetFrameId],
68
+ async ([externalId]) => {
69
+ const request = ++planRequest
33
70
  plan.value = null
34
71
  const preview = ui.spawnPreview
35
72
  if (!externalId || !preview) return
73
+ if (needsTarget.value && !targetFrameId.value) return
36
74
  loadingPlan.value = true
37
75
  try {
38
- plan.value = await documents.plan(preview.source, externalId)
76
+ const planned = await documents.plan(preview.source, externalId, targetFrameId.value)
77
+ if (request !== planRequest) return
78
+ plan.value = planned
39
79
  } catch (e) {
80
+ // A superseded request's failure is not this selection's failure: toasting it would report an
81
+ // outage against a target whose own plan may be loading fine.
82
+ if (request !== planRequest) return
40
83
  toast.add({
41
84
  title: t('documents.spawn.planFailed'),
42
85
  description: e instanceof Error ? e.message : String(e),
@@ -44,7 +87,9 @@ watch(
44
87
  color: 'error',
45
88
  })
46
89
  } finally {
47
- loadingPlan.value = false
90
+ // Only the current request may clear the spinner; an older one settling first would report
91
+ // "loaded" over a plan still on its way.
92
+ if (request === planRequest) loadingPlan.value = false
48
93
  }
49
94
  },
50
95
  { immediate: true },
@@ -55,14 +100,20 @@ async function spawn() {
55
100
  if (!preview) return
56
101
  spawning.value = true
57
102
  try {
58
- const result = await documents.spawn(preview.source, preview.externalId)
103
+ const result = await documents.spawn(preview.source, preview.externalId, targetFrameId.value)
104
+ const summary = t('documents.spawn.summary', {
105
+ frames: t('documents.spawn.frameCount', { count: result.frames }, result.frames),
106
+ modules: t('documents.spawn.moduleCount', { count: result.modules }, result.modules),
107
+ tasks: t('documents.spawn.taskCount', { count: result.tasks }, result.tasks),
108
+ })
59
109
  toast.add({
60
110
  title: t('documents.spawn.spawned'),
61
- description: t('documents.spawn.summary', {
62
- frames: t('documents.spawn.frameCount', { count: result.frames }, result.frames),
63
- modules: t('documents.spawn.moduleCount', { count: result.modules }, result.modules),
64
- tasks: t('documents.spawn.taskCount', { count: result.tasks }, result.tasks),
65
- }),
111
+ // A reused module is stated only when there was one, and never folded into the created
112
+ // count: a spawn into a service that already had every planned module otherwise reports
113
+ // "0 modules" beside its tasks, which reads as the modules having failed.
114
+ description: result.reusedModules
115
+ ? `${summary} · ${t('documents.spawn.reusedCount', { count: result.reusedModules }, result.reusedModules)}`
116
+ : summary,
66
117
  icon: 'i-lucide-check',
67
118
  color: 'success',
68
119
  })
@@ -97,9 +148,34 @@ async function spawn() {
97
148
  : t('documents.spawn.plannerHeadings')
98
149
  }}
99
150
  </UBadge>
100
- <span class="text-xs text-slate-400">{{ t('documents.spawn.asTopLevel') }}</span>
151
+ <span class="text-xs text-slate-400">{{
152
+ plan.targetFrameId ? t('documents.spawn.intoService') : t('documents.spawn.asTopLevel')
153
+ }}</span>
101
154
  </div>
102
155
 
156
+ <UFormField
157
+ v-if="needsTarget"
158
+ :label="t('documents.spawn.target.label')"
159
+ :help="t('documents.spawn.target.help')"
160
+ >
161
+ <USelectMenu
162
+ v-model="targetFrameId"
163
+ :items="frameOptions"
164
+ value-key="value"
165
+ :placeholder="t('documents.spawn.target.placeholder')"
166
+ class="w-full"
167
+ data-testid="spawn-target-frame"
168
+ />
169
+ </UFormField>
170
+
171
+ <p
172
+ v-if="needsTarget && !targetFrameId"
173
+ class="text-xs text-slate-400"
174
+ data-testid="spawn-target-required"
175
+ >
176
+ {{ t('documents.spawn.target.required') }}
177
+ </p>
178
+
103
179
  <div v-if="loadingPlan" class="flex items-center gap-2 text-sm text-slate-400">
104
180
  <UIcon name="i-lucide-loader" class="h-4 w-4 animate-spin" />
105
181
  {{ t('documents.spawn.buildingPlan') }}