@cat-factory/app 0.234.2 → 0.236.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/README.md +20 -5
- package/app/components/board/AddTaskModal.vue +31 -12
- package/app/components/board/CreateInitiativeModal.vue +33 -6
- package/app/components/context/ContextAttachmentFields.vue +63 -42
- package/app/components/documents/ContextDocumentPicker.logic.spec.ts +186 -0
- package/app/components/documents/ContextDocumentPicker.logic.ts +178 -0
- package/app/components/documents/ContextDocumentPicker.vue +269 -34
- package/app/components/pipeline/AgentKindIcon.vue +1 -1
- package/app/components/settings/ModelConfigurationPanel.vue +2 -2
- package/app/composables/api/documents.ts +10 -0
- package/app/composables/useContextLinking.spec.ts +95 -0
- package/app/composables/useContextLinking.ts +96 -15
- package/app/composables/useWorkspaceStream.ts +31 -77
- package/app/composables/workspaceStream/applyWorkspaceEvent.spec.ts +150 -0
- package/app/composables/workspaceStream/applyWorkspaceEvent.ts +173 -0
- package/app/stores/agents.ts +5 -3
- package/app/stores/documents.ts +12 -0
- package/app/types/documents.ts +2 -0
- package/app/utils/catalog.spec.ts +37 -2
- package/app/utils/catalog.ts +56 -36
- package/i18n/locales/de.json +15 -4
- package/i18n/locales/en.json +19 -2
- package/i18n/locales/es.json +15 -4
- package/i18n/locales/fr.json +15 -4
- package/i18n/locales/he.json +15 -4
- package/i18n/locales/it.json +15 -4
- package/i18n/locales/ja.json +15 -4
- package/i18n/locales/pl.json +15 -4
- package/i18n/locales/tr.json +15 -4
- package/i18n/locales/uk.json +15 -4
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -552,11 +552,26 @@ The recurring product bug behind most e2e flakes: a stale full-snapshot refresh
|
|
|
552
552
|
live state. The SPA has two delivery shapes and mixing them wrong drops live-added state with NO
|
|
553
553
|
event left to restore it.
|
|
554
554
|
|
|
555
|
-
- **Know how your entity is delivered.**
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
555
|
+
- **Know how your entity is delivered.** Targeted events (`execution`/`bootstrap`/`initiative`)
|
|
556
|
+
carry the entity and `upsert` it, so they don't clobber. A `board` event is delivered EITHER
|
|
557
|
+
way and the backend decides per change: it carries `block` when the change is fully described
|
|
558
|
+
by one (a spawned task, a field edit, a dependency toggle, a move), and carries none when it is
|
|
559
|
+
not (a removal, a reparent, a resize, a blueprint reconcile). A payload-less `board` event still
|
|
560
|
+
means a debounced full `workspace.refresh()`, where `hydrate` REPLACES whole lists. Routing
|
|
561
|
+
lives in `composables/workspaceStream/applyWorkspaceEvent.ts`, whose `switch` carries a `never`
|
|
562
|
+
guard: a new `WorkspaceEvent` member fails the BUILD rather than falling through to nothing. The
|
|
563
|
+
emit-site decision lives in `BoardService.emitBoardChanged`'s doc comment. Prefer a targeted
|
|
564
|
+
upsert for anything that must appear reliably.
|
|
565
|
+
- **Two blocks are refused a payload at the wire, on EVERY event that carries one.** Kernel's
|
|
566
|
+
`deliverableBoardBlock` is the single gate (both facades' `boardChanged` AND `bootstrapChanged`
|
|
567
|
+
assemble through it, via `boardWireEvent`/`bootstrapWireEvent`), so a new emitter cannot
|
|
568
|
+
reintroduce either by forgetting: a service FRAME, whose position and size are a per-board
|
|
569
|
+
`WorkspaceMount` override that one shared payload cannot state correctly on the several boards a
|
|
570
|
+
fan-out reaches; and a headless `internal` anchor block, which `composeBoard` filters out of
|
|
571
|
+
every snapshot and which would therefore render as a card no later read can remove. Both degrade
|
|
572
|
+
to the coarse signal, so nothing is lost but the refresh. A bootstrap's frame is always the first
|
|
573
|
+
case, which is why the `bootstrap` event's job rides live while the frame's own transitions
|
|
574
|
+
arrive as coarse `board` events beside it.
|
|
560
575
|
- **Full refreshes MUST be monotonic.** Two `refresh()` calls can be in flight; a staler one
|
|
561
576
|
resolving later overwrites the newer. `workspace.refresh()` guards this with a sequence. Do not
|
|
562
577
|
reintroduce an unguarded `hydrate(await fetch())`, and apply the guard to any new coalesced
|
|
@@ -57,7 +57,7 @@ const fragments = useFragmentsStore()
|
|
|
57
57
|
const toast = useToast()
|
|
58
58
|
const { t } = useI18n()
|
|
59
59
|
|
|
60
|
-
const { linkPending, presentLinkFailures } = useContextLinking()
|
|
60
|
+
const { resolvePending, linkPending, presentLinkFailures } = useContextLinking()
|
|
61
61
|
|
|
62
62
|
const open = computed(() => ui.addTaskContainerId !== null)
|
|
63
63
|
|
|
@@ -453,8 +453,13 @@ const resolvingIssueBodies = ref(false)
|
|
|
453
453
|
// A staged issue picked from search results carries no body yet (`needsImport`, and the
|
|
454
454
|
// search result has no description). Resolve it once the form opens — from the local cache
|
|
455
455
|
// when already imported, else by importing it (idempotent; we'd import on add anyway) — so
|
|
456
|
-
// its description can be shown read-only and folded into the task.
|
|
457
|
-
//
|
|
456
|
+
// its description can be shown read-only and folded into the task.
|
|
457
|
+
//
|
|
458
|
+
// Non-fatal (the form still opens), but NOT silent: an issue this cannot read is the very issue
|
|
459
|
+
// that will block the submit, since the fetch moved ahead of the create. Recording the cause on the
|
|
460
|
+
// item is what turns that into a warning the author sees NOW, on a chip they can remove, instead of
|
|
461
|
+
// a create refused seconds later for a reason nothing on the form ever mentioned. A tracker
|
|
462
|
+
// reference has no `parseRef`-style pre-flight to ask, so this attempt IS its pre-flight.
|
|
458
463
|
async function resolvePendingIssueBodies() {
|
|
459
464
|
const unresolved = pendingContext.value.filter(
|
|
460
465
|
(c) => c.kind === 'task' && !(c.description ?? '').trim(),
|
|
@@ -463,6 +468,7 @@ async function resolvePendingIssueBodies() {
|
|
|
463
468
|
resolvingIssueBodies.value = true
|
|
464
469
|
try {
|
|
465
470
|
const resolved: Record<string, string> = {}
|
|
471
|
+
const failed: Record<string, string> = {}
|
|
466
472
|
for (const item of unresolved) {
|
|
467
473
|
const source = item.source as TaskSourceKind
|
|
468
474
|
const cached = tasks.tasks.find(
|
|
@@ -476,15 +482,16 @@ async function resolvePendingIssueBodies() {
|
|
|
476
482
|
try {
|
|
477
483
|
const imported = await tasks.importTask(source, item.externalId)
|
|
478
484
|
if ((imported.description ?? '').trim()) resolved[contextKey(item)] = imported.description
|
|
479
|
-
} catch {
|
|
480
|
-
|
|
485
|
+
} catch (e) {
|
|
486
|
+
failed[contextKey(item)] = e instanceof Error ? e.message : String(e)
|
|
481
487
|
}
|
|
482
488
|
}
|
|
483
|
-
if (Object.keys(resolved).length) {
|
|
484
|
-
// The issue is now imported, so it links directly on add (needsImport → false).
|
|
489
|
+
if (Object.keys(resolved).length || Object.keys(failed).length) {
|
|
485
490
|
pendingContext.value = pendingContext.value.map((c) => {
|
|
486
|
-
const
|
|
487
|
-
|
|
491
|
+
const key = contextKey(c)
|
|
492
|
+
// The issue is now imported, so it links directly on add (needsImport → false).
|
|
493
|
+
if (resolved[key]) return { ...c, description: resolved[key], needsImport: false }
|
|
494
|
+
return failed[key] ? { ...c, unreadable: failed[key] } : c
|
|
488
495
|
})
|
|
489
496
|
}
|
|
490
497
|
} finally {
|
|
@@ -642,6 +649,18 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
|
|
|
642
649
|
if (!containerId) return
|
|
643
650
|
saving.value = true
|
|
644
651
|
try {
|
|
652
|
+
// Attachments are fetched BEFORE the task is written. A page that moved, a token without
|
|
653
|
+
// access or a source that is down is a correction the user can still make with the form in
|
|
654
|
+
// front of them; the same failure after the create leaves a task carrying context it never
|
|
655
|
+
// got, reported by a toast over a closed dialog.
|
|
656
|
+
const { resolved, failures } = await resolvePending(pendingContext.value)
|
|
657
|
+
pendingContext.value = resolved
|
|
658
|
+
if (failures.length) {
|
|
659
|
+
presentLinkFailures(failures, undefined, {
|
|
660
|
+
title: (count) => t('board.addTask.contextFailed', { count }, count),
|
|
661
|
+
})
|
|
662
|
+
return
|
|
663
|
+
}
|
|
645
664
|
const typeFields = buildTypeFields()
|
|
646
665
|
// The saved description includes each linked issue's body (shown read-only above)
|
|
647
666
|
// followed by the user's own notes, so the original issue description is part of the
|
|
@@ -672,9 +691,9 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
|
|
|
672
691
|
...(acknowledgeReviewDebt ? { acknowledgeReviewDebt: true } : {}),
|
|
673
692
|
})
|
|
674
693
|
if (block) {
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
694
|
+
// Everything reachable was fetched above, so what can still fail here is the LINK itself
|
|
695
|
+
// (a doc another task already holds). Surfaced with its specific cause plus a one-click
|
|
696
|
+
// "Copy details" for a bug report, and after the create because the task is already sound.
|
|
678
697
|
presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
|
|
679
698
|
}
|
|
680
699
|
ui.closeReviewFriction()
|
|
@@ -32,7 +32,7 @@ const board = useBoardStore()
|
|
|
32
32
|
const initiatives = useInitiativesStore()
|
|
33
33
|
const toast = useToast()
|
|
34
34
|
const { t } = useI18n()
|
|
35
|
-
const { linkPending, presentLinkFailures } = useContextLinking()
|
|
35
|
+
const { resolvePending, linkPending, presentLinkFailures } = useContextLinking()
|
|
36
36
|
|
|
37
37
|
const open = computed({
|
|
38
38
|
get: () => ui.createInitiativeFrameId !== null,
|
|
@@ -56,6 +56,14 @@ const description = ref('')
|
|
|
56
56
|
const inputs = ref<InitiativePresetInputs>({})
|
|
57
57
|
// Context the user chose to attach, committed once the initiative block exists (see create()).
|
|
58
58
|
const pendingContext = ref<PendingContext[]>([])
|
|
59
|
+
/**
|
|
60
|
+
* Whether THIS form is mid-submit. `initiatives.creating` cannot answer that any more: it is set
|
|
61
|
+
* inside `initiatives.create`, which the attachment fetch in `create()` now runs several network
|
|
62
|
+
* round trips ahead of. Through those seconds the button looked idle and enabled, and a second click
|
|
63
|
+
* re-entered with the ORIGINAL `pendingContext` (reassigned only after every import settles),
|
|
64
|
+
* re-imported everything and created a SECOND initiative. The add-task form's `saving` is the model.
|
|
65
|
+
*/
|
|
66
|
+
const submitting = ref(false)
|
|
59
67
|
|
|
60
68
|
// Monotonic token so a slow probe response from a since-changed preset/frame is discarded.
|
|
61
69
|
let probeSeq = 0
|
|
@@ -108,6 +116,7 @@ watch(open, (o) => {
|
|
|
108
116
|
if (!o) return
|
|
109
117
|
title.value = ''
|
|
110
118
|
description.value = ''
|
|
119
|
+
submitting.value = false
|
|
111
120
|
pendingContext.value = []
|
|
112
121
|
selectedPresetId.value = GENERIC_PRESET_ID
|
|
113
122
|
applyPreset()
|
|
@@ -119,14 +128,30 @@ const presetProblems = computed(() =>
|
|
|
119
128
|
selectedPreset.value ? validateInitiativePresetInputs(selectedPreset.value, inputs.value) : [],
|
|
120
129
|
)
|
|
121
130
|
const canSubmit = computed(
|
|
122
|
-
() =>
|
|
131
|
+
() =>
|
|
132
|
+
title.value.trim().length > 0 &&
|
|
133
|
+
presetProblems.value.length === 0 &&
|
|
134
|
+
!submitting.value &&
|
|
135
|
+
!initiatives.creating,
|
|
123
136
|
)
|
|
124
137
|
|
|
125
138
|
async function create() {
|
|
126
139
|
const frameId = ui.createInitiativeFrameId
|
|
127
140
|
if (!frameId || !canSubmit.value) return
|
|
128
141
|
const descriptor = selectedPreset.value
|
|
142
|
+
submitting.value = true
|
|
129
143
|
try {
|
|
144
|
+
// Attachments are fetched BEFORE the initiative is written, for the reason the add-task form
|
|
145
|
+
// does it: an unreachable page is a correction the user can still make here, where the same
|
|
146
|
+
// failure after the create leaves an initiative carrying context it never got.
|
|
147
|
+
const { resolved, failures } = await resolvePending(pendingContext.value)
|
|
148
|
+
pendingContext.value = resolved
|
|
149
|
+
if (failures.length) {
|
|
150
|
+
presentLinkFailures(failures, undefined, {
|
|
151
|
+
title: (count) => t('initiative.create.contextFailed', { count }, count),
|
|
152
|
+
})
|
|
153
|
+
return
|
|
154
|
+
}
|
|
130
155
|
const { block } = await initiatives.create(frameId, {
|
|
131
156
|
title: title.value.trim(),
|
|
132
157
|
description: description.value.trim() || undefined,
|
|
@@ -135,9 +160,9 @@ async function create() {
|
|
|
135
160
|
? sanitizeInitiativePresetInputs(descriptor, inputs.value)
|
|
136
161
|
: undefined,
|
|
137
162
|
})
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
163
|
+
// Everything reachable was fetched above, so what can still fail here is the LINK itself (a
|
|
164
|
+
// doc another task already holds), surfaced with its specific cause rather than a bare count.
|
|
165
|
+
// The initiative is already created, so a failed link never costs the user the form.
|
|
141
166
|
presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id, {
|
|
142
167
|
title: (count) => t('initiative.create.linkFailed', { count }, count),
|
|
143
168
|
})
|
|
@@ -151,6 +176,8 @@ async function create() {
|
|
|
151
176
|
icon: 'i-lucide-triangle-alert',
|
|
152
177
|
color: 'error',
|
|
153
178
|
})
|
|
179
|
+
} finally {
|
|
180
|
+
submitting.value = false
|
|
154
181
|
}
|
|
155
182
|
}
|
|
156
183
|
</script>
|
|
@@ -266,7 +293,7 @@ async function create() {
|
|
|
266
293
|
<UButton
|
|
267
294
|
data-testid="create-initiative-submit"
|
|
268
295
|
color="primary"
|
|
269
|
-
:loading="initiatives.creating"
|
|
296
|
+
:loading="submitting || initiatives.creating"
|
|
270
297
|
:disabled="!canSubmit"
|
|
271
298
|
@click="create"
|
|
272
299
|
>
|
|
@@ -167,29 +167,38 @@ function removePending(item: PendingContext) {
|
|
|
167
167
|
<div
|
|
168
168
|
v-for="item in pendingDocs"
|
|
169
169
|
:key="contextKey(item)"
|
|
170
|
-
class="
|
|
170
|
+
class="rounded-md border border-slate-800 bg-slate-900/60"
|
|
171
171
|
>
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
172
|
+
<div class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-slate-300">
|
|
173
|
+
<UIcon
|
|
174
|
+
:name="item.icon ?? 'i-lucide-file-text'"
|
|
175
|
+
class="h-3.5 w-3.5 shrink-0 text-indigo-400"
|
|
176
|
+
/>
|
|
177
|
+
<span class="truncate">{{ item.title }}</span>
|
|
178
|
+
<UBadge
|
|
179
|
+
v-if="item.needsImport"
|
|
180
|
+
color="neutral"
|
|
181
|
+
variant="soft"
|
|
182
|
+
size="xs"
|
|
183
|
+
class="ms-1 shrink-0"
|
|
184
|
+
>
|
|
185
|
+
{{ t('contextAttachments.importsOnAdd') }}
|
|
186
|
+
</UBadge>
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
|
|
190
|
+
@click="removePending(item)"
|
|
191
|
+
>
|
|
192
|
+
<UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
<p
|
|
196
|
+
v-if="item.unreadable"
|
|
197
|
+
class="px-2 pb-1.5 text-[11px] text-amber-400"
|
|
198
|
+
data-testid="context-item-unreadable"
|
|
183
199
|
>
|
|
184
|
-
{{ t('contextAttachments.
|
|
185
|
-
</
|
|
186
|
-
<button
|
|
187
|
-
type="button"
|
|
188
|
-
class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
|
|
189
|
-
@click="removePending(item)"
|
|
190
|
-
>
|
|
191
|
-
<UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
|
|
192
|
-
</button>
|
|
200
|
+
{{ t('contextAttachments.unreadable', { error: item.unreadable }) }}
|
|
201
|
+
</p>
|
|
193
202
|
</div>
|
|
194
203
|
</div>
|
|
195
204
|
<p v-else class="text-[11px] text-slate-500">
|
|
@@ -266,29 +275,41 @@ function removePending(item: PendingContext) {
|
|
|
266
275
|
<div
|
|
267
276
|
v-for="item in pendingIssues"
|
|
268
277
|
:key="contextKey(item)"
|
|
269
|
-
class="
|
|
278
|
+
class="rounded-md border border-slate-800 bg-slate-900/60"
|
|
270
279
|
>
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
280
|
+
<div class="flex items-center gap-1.5 px-2 py-1.5 text-xs text-slate-300">
|
|
281
|
+
<UIcon
|
|
282
|
+
:name="item.icon ?? 'i-lucide-square-check'"
|
|
283
|
+
class="h-3.5 w-3.5 shrink-0 text-indigo-400"
|
|
284
|
+
/>
|
|
285
|
+
<span class="truncate">{{ item.title }}</span>
|
|
286
|
+
<UBadge
|
|
287
|
+
v-if="item.needsImport"
|
|
288
|
+
color="neutral"
|
|
289
|
+
variant="soft"
|
|
290
|
+
size="xs"
|
|
291
|
+
class="ms-1 shrink-0"
|
|
292
|
+
>
|
|
293
|
+
{{ t('contextAttachments.importsOnAdd') }}
|
|
294
|
+
</UBadge>
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
|
|
298
|
+
@click="removePending(item)"
|
|
299
|
+
>
|
|
300
|
+
<UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
<!-- An issue reference gets no pre-flight of its own (there is no `parseRef` to ask a
|
|
304
|
+
tracker), so this line IS its warning: the fetch is attempted when the form opens and
|
|
305
|
+
again on submit, and a failure now blocks the create. -->
|
|
306
|
+
<p
|
|
307
|
+
v-if="item.unreadable"
|
|
308
|
+
class="px-2 pb-1.5 text-[11px] text-amber-400"
|
|
309
|
+
data-testid="context-item-unreadable"
|
|
289
310
|
>
|
|
290
|
-
|
|
291
|
-
</
|
|
311
|
+
{{ t('contextAttachments.unreadable', { error: item.unreadable }) }}
|
|
312
|
+
</p>
|
|
292
313
|
</div>
|
|
293
314
|
</div>
|
|
294
315
|
<p v-else class="text-[11px] text-slate-500">
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { ResolvedDocumentRef } from '@cat-factory/contracts'
|
|
3
|
+
import { ApiError } from '~/composables/api/errors'
|
|
4
|
+
import {
|
|
5
|
+
classifyRefFailure,
|
|
6
|
+
refCandidateOf,
|
|
7
|
+
refRowFor,
|
|
8
|
+
type RefState,
|
|
9
|
+
} from '~/components/documents/ContextDocumentPicker.logic'
|
|
10
|
+
|
|
11
|
+
// The picker used to stage whatever text sat in its input, so a Figma share link (title segment
|
|
12
|
+
// plus `?p=`/`&t=` params) was accepted verbatim and a link the source could not read at all was
|
|
13
|
+
// accepted just as readily, the verdict arriving as a failed import after the task was created.
|
|
14
|
+
// These three functions are what makes the verdict arrive first, and each is pinned here.
|
|
15
|
+
|
|
16
|
+
/** The refusal envelope the resolve endpoint answers with, as a thrown `ApiError`. */
|
|
17
|
+
function refusal(details: Record<string, unknown>): ApiError {
|
|
18
|
+
return new ApiError(422, { error: { code: 'validation', message: 'nope', details } })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('refCandidateOf', () => {
|
|
22
|
+
it('treats any non-empty text as a reference for a source with no catalogue search', () => {
|
|
23
|
+
// Pasting is the only way to attach a page to such a source, so a bare id must resolve.
|
|
24
|
+
expect(refCandidateOf(' 6k0gqOC6ppDMAziCmZ2Gv9 ', false)).toBe('6k0gqOC6ppDMAziCmZ2Gv9')
|
|
25
|
+
expect(refCandidateOf(' ', false)).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('leaves a plain search phrase alone on a searchable source', () => {
|
|
29
|
+
// Resolving every keystroke of a title search would render a refusal at someone who is
|
|
30
|
+
// simply searching, which is the opposite of the point.
|
|
31
|
+
expect(refCandidateOf('export requirements', true)).toBeNull()
|
|
32
|
+
expect(refCandidateOf('https://www.figma.com/design/K/T?node-id=1-2', true)).toBe(
|
|
33
|
+
'https://www.figma.com/design/K/T?node-id=1-2',
|
|
34
|
+
)
|
|
35
|
+
// A link with the scheme pasted off is still a link.
|
|
36
|
+
expect(refCandidateOf('notion.so/Checkout-PRD-1f2e3d', true)).toBe(
|
|
37
|
+
'notion.so/Checkout-PRD-1f2e3d',
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('does not read a PHRASE as a reference just because it carries punctuation', () => {
|
|
42
|
+
// The consequence of a false positive changed with this surface: it used to cost an ignorable
|
|
43
|
+
// extra row, and now it renders "Not a Notion reference" in amber above the results for that
|
|
44
|
+
// very phrase. Whitespace is the tell, so `/` and `#` inside a title search no longer qualify.
|
|
45
|
+
expect(refCandidateOf('auth/login flow', true)).toBeNull()
|
|
46
|
+
expect(refCandidateOf('sprint #4 plan', true)).toBeNull()
|
|
47
|
+
expect(refCandidateOf('roadmap Q3/Q4', true)).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('accepts the bare id shapes no title could be confused with', () => {
|
|
51
|
+
// Worth ASKING about (the backend stays the judge): a Notion id dashed or dashless, and a
|
|
52
|
+
// Confluence page id. A single unrecognised word is a search, not an id.
|
|
53
|
+
expect(refCandidateOf('1f2e3d4c5b6a78901234567890abcdef', true)).toBe(
|
|
54
|
+
'1f2e3d4c5b6a78901234567890abcdef',
|
|
55
|
+
)
|
|
56
|
+
expect(refCandidateOf('1f2e3d4c-5b6a-7890-1234-567890abcdef', true)).toBe(
|
|
57
|
+
'1f2e3d4c-5b6a-7890-1234-567890abcdef',
|
|
58
|
+
)
|
|
59
|
+
expect(refCandidateOf('123456', true)).toBe('123456')
|
|
60
|
+
expect(refCandidateOf('authentication', true)).toBeNull()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('classifyRefFailure', () => {
|
|
65
|
+
it('reads the two refusal reasons and the detail each correction needs', () => {
|
|
66
|
+
expect(
|
|
67
|
+
classifyRefFailure(
|
|
68
|
+
refusal({ reason: 'document_ref_claimed_by_other_source', claimedBy: 'figma' }),
|
|
69
|
+
),
|
|
70
|
+
).toEqual({
|
|
71
|
+
status: 'rejected',
|
|
72
|
+
reason: 'document_ref_claimed_by_other_source',
|
|
73
|
+
claimedBy: 'figma',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(
|
|
77
|
+
classifyRefFailure(refusal({ reason: 'document_ref_unrecognized', expected: 'https://…' })),
|
|
78
|
+
).toEqual({ status: 'rejected', reason: 'document_ref_unrecognized', expected: 'https://…' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('leaves the reference UNCHECKED when the call failed rather than the source refusing', () => {
|
|
82
|
+
// A 502, an offline browser or a proxy's own error page says nothing about the link. Reading
|
|
83
|
+
// any error as a refusal would send the user off to fix a link that was fine.
|
|
84
|
+
expect(classifyRefFailure(new Error('Failed to fetch'))).toEqual({
|
|
85
|
+
status: 'unchecked',
|
|
86
|
+
message: 'Failed to fetch',
|
|
87
|
+
})
|
|
88
|
+
expect(classifyRefFailure(new ApiError(502, '<html>bad gateway</html>')).status).toBe(
|
|
89
|
+
'unchecked',
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does not trust a reason outside the contract vocabulary', () => {
|
|
94
|
+
// An older or newer backend can name a reason this build has no copy for; rendering it as a
|
|
95
|
+
// refusal would show the user a blank explanation for a link that may be perfectly good.
|
|
96
|
+
expect(classifyRefFailure(refusal({ reason: 'something_new' })).status).toBe('unchecked')
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('refRowFor', () => {
|
|
101
|
+
const resolved = {
|
|
102
|
+
source: 'figma' as const,
|
|
103
|
+
externalId: '6k0gqOC6ppDMAziCmZ2Gv9:5765:57229',
|
|
104
|
+
canonicalUrl: 'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9?node-id=5765-57229',
|
|
105
|
+
droppedScope: null,
|
|
106
|
+
}
|
|
107
|
+
const ok = (ref: ResolvedDocumentRef): RefState => ({ status: 'ok', ref })
|
|
108
|
+
|
|
109
|
+
it('labels the row with the CANONICAL form and flags that the paste was trimmed', () => {
|
|
110
|
+
const row = refRowFor(
|
|
111
|
+
ok(resolved),
|
|
112
|
+
'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9/Project-Redwood--Autopilot-AI-' +
|
|
113
|
+
'?node-id=5765-57229&p=f&t=J1SrKp6sgJm9CIeQ-0',
|
|
114
|
+
)
|
|
115
|
+
expect(row).toEqual({
|
|
116
|
+
externalId: resolved.externalId,
|
|
117
|
+
source: 'figma',
|
|
118
|
+
canonicalUrl: resolved.canonicalUrl,
|
|
119
|
+
label: resolved.canonicalUrl,
|
|
120
|
+
trimmed: true,
|
|
121
|
+
droppedScope: null,
|
|
122
|
+
unchecked: false,
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not claim a trim when the canonical form is what was pasted', () => {
|
|
127
|
+
expect(refRowFor(ok(resolved), ` ${resolved.canonicalUrl} `)).toMatchObject({ trimmed: false })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('carries a WIDENED reference separately from a trim', () => {
|
|
131
|
+
// The two are opposite facts wearing the same clothes. A trim resolves the same page; dropping
|
|
132
|
+
// an unreadable `node-id` swaps one frame for the whole design file, and the label alone cannot
|
|
133
|
+
// show it (a whole-file canonical URL looks perfectly well-formed). A row that reported only
|
|
134
|
+
// `trimmed` here is how "I attached this frame" became "the agent read everything".
|
|
135
|
+
const widened = {
|
|
136
|
+
source: 'figma' as const,
|
|
137
|
+
externalId: '6k0gqOC6ppDMAziCmZ2Gv9',
|
|
138
|
+
canonicalUrl: 'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9',
|
|
139
|
+
droppedScope: 'I2649:14930;2649:14746',
|
|
140
|
+
}
|
|
141
|
+
const row = refRowFor(
|
|
142
|
+
ok(widened),
|
|
143
|
+
'https://www.figma.com/design/6k0gqOC6ppDMAziCmZ2Gv9/R?node-id=I2649:14930;2649:14746',
|
|
144
|
+
)
|
|
145
|
+
expect(row).toMatchObject({ trimmed: true, droppedScope: 'I2649:14930;2649:14746' })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('prefers the imported page title, and falls back to the id when there is no URL to show', () => {
|
|
149
|
+
expect(refRowFor(ok(resolved), resolved.canonicalUrl, 'Autopilot Home')?.label).toBe(
|
|
150
|
+
'Autopilot Home',
|
|
151
|
+
)
|
|
152
|
+
// A Confluence page id cannot be rendered back as a URL without the site base, so the id
|
|
153
|
+
// itself is the canonical form, not a missing one.
|
|
154
|
+
const noUrl = {
|
|
155
|
+
source: 'confluence' as const,
|
|
156
|
+
externalId: '4567',
|
|
157
|
+
canonicalUrl: null,
|
|
158
|
+
droppedScope: null,
|
|
159
|
+
}
|
|
160
|
+
expect(refRowFor(ok(noUrl), '4567')).toMatchObject({ label: '4567', trimmed: false })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('still offers an UNJUDGED reference, carrying the pasted text', () => {
|
|
164
|
+
// "The source refused this" and "we could not ask" are different facts, and only the first is
|
|
165
|
+
// evidence against a link. Suppressing the row for the second made a transient 502 or an
|
|
166
|
+
// offline moment as final as a refusal: attaching a perfectly good link became impossible,
|
|
167
|
+
// where the import had always been the backstop.
|
|
168
|
+
const row = refRowFor({ status: 'unchecked', message: 'Failed to fetch' }, ' notion.so/abc ')
|
|
169
|
+
expect(row).toEqual({
|
|
170
|
+
externalId: 'notion.so/abc',
|
|
171
|
+
// Only the resolve answers which source claims a paste; the picker supplies the selected one.
|
|
172
|
+
source: null,
|
|
173
|
+
canonicalUrl: null,
|
|
174
|
+
label: 'notion.so/abc',
|
|
175
|
+
trimmed: false,
|
|
176
|
+
droppedScope: null,
|
|
177
|
+
unchecked: true,
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('offers nothing while checking, or once the source has refused', () => {
|
|
182
|
+
expect(refRowFor({ status: 'none' }, 'x')).toBeNull()
|
|
183
|
+
expect(refRowFor({ status: 'checking' }, 'x')).toBeNull()
|
|
184
|
+
expect(refRowFor({ status: 'rejected', reason: 'document_ref_unrecognized' }, 'x')).toBeNull()
|
|
185
|
+
})
|
|
186
|
+
})
|