@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.
- package/app/components/board/AddTaskModal.vue +1 -0
- package/app/components/board/nodes/BlockNode.vue +24 -0
- package/app/components/context/ContextAttachmentFields.vue +102 -0
- package/app/components/context/pastedLinkOffer.logic.spec.ts +35 -0
- package/app/components/context/pastedLinkOffer.logic.ts +50 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +50 -0
- package/app/components/documents/SpawnPreviewModal.vue +92 -16
- package/app/components/documents/StartFromDesignModal.vue +237 -0
- package/app/components/layout/AccountDeploymentSettings.vue +122 -0
- package/app/composables/api/documents.ts +17 -6
- package/app/composables/useDocumentSourceConnect.ts +88 -0
- package/app/modular/external-tools.spec.ts +1 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +10 -0
- package/app/modular/nav-gates.ts +4 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -0
- package/app/modular/tutorial-tours.ts +77 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/documents.spec.ts +60 -0
- package/app/stores/documents.ts +52 -24
- package/app/stores/ui/modals.ts +15 -0
- package/i18n/locales/de.json +82 -6
- package/i18n/locales/en.json +82 -6
- package/i18n/locales/es.json +82 -6
- package/i18n/locales/fr.json +82 -6
- package/i18n/locales/he.json +82 -6
- package/i18n/locales/it.json +82 -6
- package/i18n/locales/ja.json +82 -6
- package/i18n/locales/pl.json +82 -6
- package/i18n/locales/tr.json +82 -6
- package/i18n/locales/uk.json +82 -6
- 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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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">{{
|
|
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') }}
|