@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.
Files changed (31) hide show
  1. package/README.md +20 -5
  2. package/app/components/board/AddTaskModal.vue +31 -12
  3. package/app/components/board/CreateInitiativeModal.vue +33 -6
  4. package/app/components/context/ContextAttachmentFields.vue +63 -42
  5. package/app/components/documents/ContextDocumentPicker.logic.spec.ts +186 -0
  6. package/app/components/documents/ContextDocumentPicker.logic.ts +178 -0
  7. package/app/components/documents/ContextDocumentPicker.vue +269 -34
  8. package/app/components/pipeline/AgentKindIcon.vue +1 -1
  9. package/app/components/settings/ModelConfigurationPanel.vue +2 -2
  10. package/app/composables/api/documents.ts +10 -0
  11. package/app/composables/useContextLinking.spec.ts +95 -0
  12. package/app/composables/useContextLinking.ts +96 -15
  13. package/app/composables/useWorkspaceStream.ts +31 -77
  14. package/app/composables/workspaceStream/applyWorkspaceEvent.spec.ts +150 -0
  15. package/app/composables/workspaceStream/applyWorkspaceEvent.ts +173 -0
  16. package/app/stores/agents.ts +5 -3
  17. package/app/stores/documents.ts +12 -0
  18. package/app/types/documents.ts +2 -0
  19. package/app/utils/catalog.spec.ts +37 -2
  20. package/app/utils/catalog.ts +56 -36
  21. package/i18n/locales/de.json +15 -4
  22. package/i18n/locales/en.json +19 -2
  23. package/i18n/locales/es.json +15 -4
  24. package/i18n/locales/fr.json +15 -4
  25. package/i18n/locales/he.json +15 -4
  26. package/i18n/locales/it.json +15 -4
  27. package/i18n/locales/ja.json +15 -4
  28. package/i18n/locales/pl.json +15 -4
  29. package/i18n/locales/tr.json +15 -4
  30. package/i18n/locales/uk.json +15 -4
  31. 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.** A `board` event is COARSE: no payload, only a debounced
556
- full `workspace.refresh()`, and `hydrate` REPLACES whole lists. A spawned task/module block
557
- reaches the browser ONLY this way. Targeted events (`execution`/`bootstrap`/`initiative`) carry
558
- the entity and `upsert` it, so they don't clobber. Prefer a targeted upsert for anything that
559
- must appear reliably.
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. Best-effort: a failure
457
- // just leaves that issue without a preview, still linked on add.
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
- // Unreadable/forbidden issue skip the preview; it still links on add.
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 body = resolved[contextKey(c)]
487
- return body ? { ...c, description: body, needsImport: false } : c
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
- // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
676
- // permission/visibility error, a not-found doc, ) instead of a bare count, plus a
677
- // one-click "Copy details" for a bug report.
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
- () => title.value.trim().length > 0 && presetProblems.value.length === 0 && !initiatives.creating,
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
- // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
139
- // permission/visibility error, a not-found doc, …) rather than a bare count. The initiative
140
- // itself is already created, so a failed attachment never costs the user the form.
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="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
170
+ class="rounded-md border border-slate-800 bg-slate-900/60"
171
171
  >
172
- <UIcon
173
- :name="item.icon ?? 'i-lucide-file-text'"
174
- class="h-3.5 w-3.5 shrink-0 text-indigo-400"
175
- />
176
- <span class="truncate">{{ item.title }}</span>
177
- <UBadge
178
- v-if="item.needsImport"
179
- color="neutral"
180
- variant="soft"
181
- size="xs"
182
- class="ms-1 shrink-0"
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.importsOnAdd') }}
185
- </UBadge>
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="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
278
+ class="rounded-md border border-slate-800 bg-slate-900/60"
270
279
  >
271
- <UIcon
272
- :name="item.icon ?? 'i-lucide-square-check'"
273
- class="h-3.5 w-3.5 shrink-0 text-indigo-400"
274
- />
275
- <span class="truncate">{{ item.title }}</span>
276
- <UBadge
277
- v-if="item.needsImport"
278
- color="neutral"
279
- variant="soft"
280
- size="xs"
281
- class="ms-1 shrink-0"
282
- >
283
- {{ t('contextAttachments.importsOnAdd') }}
284
- </UBadge>
285
- <button
286
- type="button"
287
- class="ms-auto shrink-0 text-slate-400 hover:text-slate-200"
288
- @click="removePending(item)"
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
- <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
291
- </button>
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
+ })