@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
@@ -0,0 +1,178 @@
1
+ import {
2
+ documentRefReasonSchema,
3
+ type DocumentRefReason,
4
+ type DocumentSourceKind,
5
+ type ResolvedDocumentRef,
6
+ } from '@cat-factory/contracts'
7
+ import { apiErrorEnvelope, apiErrorReason } from '~/composables/api/errors'
8
+
9
+ // The pure half of ContextDocumentPicker: deciding what counts as a pasted REFERENCE rather than a
10
+ // search phrase, reading the backend's verdict on one, and describing the row it becomes.
11
+ // Extracted for the reason every `*.logic.ts` here is (a decision worth a test should not need a
12
+ // mounted component to reach), and these three carry the whole "don't silently accept a bad link"
13
+ // rule the picker exists to enforce.
14
+
15
+ /** What the picker knows about the reference currently in its input. */
16
+ export type RefState =
17
+ /** Nothing pasted, or the text reads as a search phrase. */
18
+ | { status: 'none' }
19
+ | { status: 'checking' }
20
+ | { status: 'ok'; ref: ResolvedDocumentRef }
21
+ /** The SOURCE refused it. `reason` decides which correction the user is offered. */
22
+ | { status: 'rejected'; reason: DocumentRefReason; claimedBy?: string; expected?: string }
23
+ /**
24
+ * The resolve call itself failed (offline, 5xx). The reference is UNJUDGED, not refused, and
25
+ * saying so matters: a pre-flight outage rendered as "your link is wrong" sends the user off to
26
+ * fix a link that was fine.
27
+ */
28
+ | { status: 'unchecked'; message: string }
29
+
30
+ /**
31
+ * The text to resolve as a reference, or null when there is nothing to resolve.
32
+ *
33
+ * A source with no catalogue search takes any non-empty text (pasting is the only way to attach a
34
+ * page there, mirroring the import modal's single input). A searchable one only treats text that
35
+ * READS as a reference that way, so typing a title does not fire a resolve on every keystroke and
36
+ * does not render a refusal at someone who is simply searching.
37
+ */
38
+ export function refCandidateOf(query: string, searchable: boolean): string | null {
39
+ const trimmed = query.trim()
40
+ if (!trimmed) return null
41
+ if (!searchable) return trimmed
42
+ return looksLikeDocumentRef(trimmed) ? trimmed : null
43
+ }
44
+
45
+ /** The bare-id forms a document source's ref grammar accepts UNAMBIGUOUSLY, so no phrase matches. */
46
+ const BARE_ID_SHAPES = [
47
+ /** A Notion page id, dashless. */
48
+ /^[0-9a-f]{32}$/i,
49
+ /** A dashed UUID (Notion, Linear). */
50
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
51
+ /** A Confluence page id. */
52
+ /^\d{4,}$/,
53
+ ]
54
+
55
+ /**
56
+ * Whether text typed into a SEARCHABLE source's box is a reference rather than a search phrase.
57
+ *
58
+ * Deliberately narrow, because the consequence of a false positive changed. It used to cost an
59
+ * extra row the user could ignore; now the resolve verdict is RENDERED, so a phrase mistaken for a
60
+ * reference produces "Not a Notion reference" underneath the search box, in amber, above the
61
+ * results for that same phrase. `auth/login flow` and `sprint #4 plan` are searches, so the first
62
+ * rule is that anything containing whitespace is one, whatever punctuation it carries: the old
63
+ * "contains `/` or `#`" test read both of those as malformed links.
64
+ *
65
+ * What remains is a URL (with or without its scheme, which people routinely paste off), or one of
66
+ * the `BARE_ID_SHAPES` no title could be confused with. A bare id in an unrecognised shape simply
67
+ * is not offered here, which is the pre-existing behaviour: the backend stays the judge of every
68
+ * candidate this admits, and this only decides which ones are worth ASKING about.
69
+ */
70
+ function looksLikeDocumentRef(text: string): boolean {
71
+ if (/\s/.test(text)) return false
72
+ if (/^https?:\/\//i.test(text)) return true
73
+ if (/^[\w-]+(\.[\w-]+)+\/\S/.test(text)) return true
74
+ return BARE_ID_SHAPES.some((shape) => shape.test(text))
75
+ }
76
+
77
+ /**
78
+ * Classify a failed resolve.
79
+ *
80
+ * A refusal is only a refusal when the backend NAMED a reason we know: the reason vocabulary is
81
+ * the closed contract picklist, so an unrecognised value (an older/newer backend, a proxy's own
82
+ * error page, a network fault) lands as `unchecked`. Guessing "rejected" from the mere presence of
83
+ * an error is the misattribution this whole surface is meant to avoid: it would tell the user
84
+ * their link is malformed on the strength of a 502.
85
+ *
86
+ * The reason is read through `apiErrorReason`, the one helper that normalises this contract (which
87
+ * client threw, a non-object `details`, a non-string `reason`); only the two extra details each
88
+ * correction needs are picked off the envelope here.
89
+ */
90
+ export function classifyRefFailure(error: unknown): RefState {
91
+ const reason = apiErrorReason(error)
92
+ if (!isRefReason(reason)) {
93
+ return { status: 'unchecked', message: error instanceof Error ? error.message : String(error) }
94
+ }
95
+ const details = (apiErrorEnvelope(error)?.details ?? {}) as Record<string, unknown>
96
+ return {
97
+ status: 'rejected',
98
+ reason,
99
+ ...(typeof details.claimedBy === 'string' ? { claimedBy: details.claimedBy } : {}),
100
+ ...(typeof details.expected === 'string' ? { expected: details.expected } : {}),
101
+ }
102
+ }
103
+
104
+ /** Narrow an unknown `details.reason` to the contract's own picklist, never a bare cast. */
105
+ function isRefReason(value: unknown): value is DocumentRefReason {
106
+ return (
107
+ typeof value === 'string' &&
108
+ (documentRefReasonSchema.options as readonly string[]).includes(value)
109
+ )
110
+ }
111
+
112
+ /** How a reference is presented as an attachable row. */
113
+ export interface RefRow {
114
+ /** The reference to stage: the resolved canonical id, or the pasted text when unjudged. */
115
+ externalId: string
116
+ /** The source that claimed it; null when the pre-flight never got a verdict. */
117
+ source: DocumentSourceKind | null
118
+ /** The canonical link, when the source can rebuild one from the id alone. */
119
+ canonicalUrl: string | null
120
+ /** What the row reads as: the imported page's title when we hold it, else the canonical form. */
121
+ label: string
122
+ /** The paste carried NOISE the canonical form drops, so the trim is worth stating explicitly. */
123
+ trimmed: boolean
124
+ /**
125
+ * The frame/screen the paste named that this reference does NOT cover (see the contract's
126
+ * `droppedScope`). Kept apart from {@link trimmed} because they are opposite facts wearing the
127
+ * same clothes: a trim resolves the same page, a drop WIDENS one frame to a whole design file.
128
+ */
129
+ droppedScope: string | null
130
+ /** The pre-flight could not reach a verdict. Stageable, with the import as the backstop. */
131
+ unchecked: boolean
132
+ }
133
+
134
+ /**
135
+ * Describe the row a reference becomes, or null when there is nothing to offer.
136
+ *
137
+ * The label is the CANONICAL form, never the pasted text: showing what was typed would hide the
138
+ * one thing worth confirming, which is that a share link's title segment and `?p=`/`&t=` tracking
139
+ * params are gone and the frame the URL named survived the trim. `trimmed` is what lets the row
140
+ * say so instead of leaving the change to be noticed, and `droppedScope` is what keeps a widened
141
+ * reference from hiding behind that same note.
142
+ *
143
+ * An UNCHECKED reference still yields a row, carrying the pasted text. "The source refused this"
144
+ * and "we could not ask" are different facts and only the first is a reason to refuse a paste: a
145
+ * transient 502 or an offline moment must not make attaching a perfectly good link impossible,
146
+ * which is what suppressing the row does. The import remains the backstop it always was, so the
147
+ * worst case is the pre-PR behaviour rather than a dead end.
148
+ */
149
+ export function refRowFor(state: RefState, pasted: string, importedTitle?: string): RefRow | null {
150
+ if (state.status === 'ok') {
151
+ const canonical = state.ref.canonicalUrl ?? state.ref.externalId
152
+ return {
153
+ externalId: state.ref.externalId,
154
+ source: state.ref.source,
155
+ canonicalUrl: state.ref.canonicalUrl,
156
+ label: importedTitle ?? canonical,
157
+ trimmed: canonical !== pasted.trim(),
158
+ droppedScope: state.ref.droppedScope,
159
+ unchecked: false,
160
+ }
161
+ }
162
+ if (state.status === 'unchecked') {
163
+ const text = pasted.trim()
164
+ if (!text) return null
165
+ return {
166
+ externalId: text,
167
+ // Unknown: only the resolve answers which source claims a paste, and guessing it here is
168
+ // what would stage a Figma link against the Notion picker's key space.
169
+ source: null,
170
+ canonicalUrl: null,
171
+ label: text,
172
+ trimmed: false,
173
+ droppedScope: null,
174
+ unchecked: true,
175
+ }
176
+ }
177
+ return null
178
+ }
@@ -8,7 +8,31 @@
8
8
  // collects PendingContext items and links them once the block exists (see
9
9
  // useContextLinking). A search hit / pasted ref carries `needsImport: true` so
10
10
  // it's fetched + persisted before linking. Mirrors ContextIssuePicker.
11
- import type { DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
11
+ //
12
+ // A PASTED REF IS RESOLVED BEFORE IT CAN BE STAGED, and that is the point of the ref
13
+ // half of this picker. It used to stage whatever text was in the box, so a share link
14
+ // carrying a title segment and `?p=`/`&t=` tracking params (what Figma's own Copy link
15
+ // button produces) was accepted verbatim, and a link the source could not read at all
16
+ // was accepted just as readily. The verdict arrived as a failed import AFTER the task
17
+ // had been created. The resolve call is the source's own `parseRef`, so what is shown
18
+ // here is what the import will do: the canonical trimmed link when it parses, and one
19
+ // of two named corrections when it does not (see `REF_REJECTIONS`).
20
+ //
21
+ // Two distinctions the row has to keep, because collapsing either one recreates the bug in
22
+ // quieter form. A reference the source could parse only by DROPPING the frame the link named
23
+ // carries that fact separately from the trim (`droppedScope`): both change what was pasted,
24
+ // but one resolves the same page and the other attaches the whole design file. And a resolve
25
+ // call that could not be MADE leaves the reference unjudged rather than refused, still
26
+ // stageable with the import as the backstop, because only the source's own refusal is evidence
27
+ // against a link. An outage that made attaching impossible would be a worse failure than the
28
+ // one the pre-flight fixes.
29
+ import type { DocumentRefReason, DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
30
+ import {
31
+ classifyRefFailure,
32
+ refCandidateOf,
33
+ refRowFor,
34
+ type RefState,
35
+ } from '~/components/documents/ContextDocumentPicker.logic'
12
36
  import EmptyState from '~/components/common/EmptyState.vue'
13
37
  import RepoContextDocPicker from '~/components/documents/RepoContextDocPicker.vue'
14
38
 
@@ -46,6 +70,45 @@ const results = ref<DocumentSearchResult[]>([])
46
70
  const searching = ref(false)
47
71
  const searchError = ref<string | null>(null)
48
72
 
73
+ // What the ref half of the picker knows about the current query. A refusal carries the backend's
74
+ // machine-readable REASON rather than its prose, because the two reasons ask for different
75
+ // corrections and only one of them is fixable from here (see `REF_REJECTIONS`).
76
+ const refState = ref<RefState>({ status: 'none' })
77
+ // Monotonic token so a slow resolve for an earlier query cannot land on a later one: the
78
+ // input is resolved on every keystroke, so out-of-order responses are the normal case.
79
+ let refSeq = 0
80
+
81
+ /** A source's display name, for a kind the backend named in a refusal (never a raw slug). */
82
+ function sourceLabel(kind: string): string {
83
+ return documents.sources.find((s) => s.source === kind)?.label ?? kind
84
+ }
85
+
86
+ /**
87
+ * Translated copy per refusal reason, an exhaustive `Record` over the closed union so a reason
88
+ * added on the backend fails the typecheck here rather than reaching the user as English server
89
+ * prose. Each message names the correction that reason actually calls for.
90
+ */
91
+ const REF_REJECTIONS: Record<
92
+ DocumentRefReason,
93
+ (input: { claimedBy?: string; expected?: string }) => string
94
+ > = {
95
+ document_ref_unrecognized: ({ expected }) =>
96
+ t('documents.picker.refUnrecognized', {
97
+ source: sourceLabel(source.value ?? ''),
98
+ expected: expected ?? descriptor.value?.refPlaceholder ?? '',
99
+ }),
100
+ document_ref_claimed_by_other_source: ({ claimedBy }) =>
101
+ t('documents.picker.refOtherSource', {
102
+ source: sourceLabel(source.value ?? ''),
103
+ claimed: sourceLabel(claimedBy ?? ''),
104
+ }),
105
+ }
106
+
107
+ /** The text to resolve as a reference, or null when there is nothing to resolve. */
108
+ const refCandidate = computed(() =>
109
+ source.value ? refCandidateOf(query.value, searchable.value) : null,
110
+ )
111
+
49
112
  // Debounced search: free text hits the source; a query that's clearly a URL/ID
50
113
  // is left to the explicit "by reference" row below (search won't surface it).
51
114
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -53,9 +116,14 @@ watch([query, source], () => {
53
116
  if (timer) clearTimeout(timer)
54
117
  results.value = []
55
118
  searchError.value = null
119
+ refState.value = { status: 'none' }
120
+ refSeq++
56
121
  const q = query.value.trim()
57
- if (!q || !searchable.value) return
58
- timer = setTimeout(runSearch, 300)
122
+ if (!q) return
123
+ timer = setTimeout(() => {
124
+ if (searchable.value) void runSearch()
125
+ if (refCandidate.value) void resolveRef(refCandidate.value)
126
+ }, 300)
59
127
  })
60
128
 
61
129
  async function runSearch() {
@@ -73,6 +141,26 @@ async function runSearch() {
73
141
  }
74
142
  }
75
143
 
144
+ /**
145
+ * Ask the backend what this text resolves to for the selected source. A 422 is the source's
146
+ * own refusal and lands as `rejected` with its reason; anything else (offline, 5xx) leaves
147
+ * the ref UNCHECKED rather than refused, so an outage in the pre-flight never reads to the
148
+ * user as "your link is wrong".
149
+ */
150
+ async function resolveRef(candidate: string) {
151
+ const src = source.value
152
+ if (!src) return
153
+ const seq = ++refSeq
154
+ refState.value = { status: 'checking' }
155
+ try {
156
+ const ref = await documents.resolveRef(src, candidate)
157
+ if (seq === refSeq) refState.value = { status: 'ok', ref }
158
+ } catch (e) {
159
+ if (seq !== refSeq) return
160
+ refState.value = classifyRefFailure(e)
161
+ }
162
+ }
163
+
76
164
  const icon = computed(() => descriptor.value?.icon ?? 'i-lucide-file-text')
77
165
 
78
166
  function keyFor(externalId: string): string {
@@ -103,30 +191,96 @@ const searchRows = computed(() => {
103
191
  .filter((r) => !chosen.value.has(keyFor(r.externalId)))
104
192
  })
105
193
 
106
- // A pasted URL / ID the search won't match: offer it as an explicit reference.
107
- // When the source isn't searchable, any non-empty query is treated as a ref (the
108
- // only way to attach a page there, mirroring the import modal's single input).
194
+ /** The already-imported document a resolved ref points at, when the workspace holds it. */
195
+ const refImported = computed(() => {
196
+ const state = refState.value
197
+ if (state.status !== 'ok') return undefined
198
+ return documents.documents.find(
199
+ (d) => d.source === state.ref.source && d.externalId === state.ref.externalId,
200
+ )
201
+ })
202
+
203
+ /** Every id another row in this dropdown already offers, so one page is never listed twice. */
204
+ const offeredIds = computed(
205
+ () =>
206
+ new Set([
207
+ ...importedRows.value.map((d) => d.externalId),
208
+ ...searchRows.value.map((r) => r.externalId),
209
+ ]),
210
+ )
211
+
212
+ /**
213
+ * The attachable row for the reference in the box: the CANONICAL form the source settled on, never
214
+ * the text that was typed (see `refRowFor`, which also carries the unjudged case).
215
+ *
216
+ * Suppressed in two cases, each with its own reason. Already STAGED, keyed on the resolved external
217
+ * id so pasting the share link and then the bare id cannot stage it twice (the `refAlreadyAttached`
218
+ * line says so, since a row that silently vanishes reads as a picker that lost the paste). Already
219
+ * OFFERED by an imported/search row above, which is the same page reachable by one click: the
220
+ * dedupe is against what is VISIBLE rather than against the whole imported list, because a URL
221
+ * query matches no title, so testing the full list would suppress the only row on offer.
222
+ */
109
223
  const refRow = computed(() => {
110
- const q = query.value.trim()
111
- if (!q || !source.value) return null
112
- const known =
113
- importedRows.value.some((d) => d.externalId === q) ||
114
- searchRows.value.some((r) => r.externalId === q) ||
115
- chosen.value.has(keyFor(q))
116
- if (known) return null
117
- if (!searchable.value) return q
118
- // Only worth offering when it looks like a reference, not a search phrase.
119
- const looksLikeRef = q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q)
120
- return looksLikeRef ? q : null
224
+ const pasted = refCandidate.value
225
+ if (!pasted) return null
226
+ const row = refRowFor(refState.value, pasted, refImported.value?.title)
227
+ if (!row) return null
228
+ if (chosen.value.has(keyFor(row.externalId)) || offeredIds.value.has(row.externalId)) return null
229
+ return { ...row, imported: !!refImported.value }
230
+ })
231
+
232
+ /** A resolved reference the caller has already staged: suppressed as a row, stated as a line. */
233
+ const refAlreadyAttached = computed(() => {
234
+ const state = refState.value
235
+ return state.status === 'ok' && chosen.value.has(keyFor(state.ref.externalId))
121
236
  })
122
237
 
238
+ /** The refusal to render under the input, in the reader's language, or null. */
239
+ const refRejection = computed(() => {
240
+ const state = refState.value
241
+ if (state.status !== 'rejected') return null
242
+ return REF_REJECTIONS[state.reason]({
243
+ ...(state.claimedBy ? { claimedBy: state.claimedBy } : {}),
244
+ ...(state.expected ? { expected: state.expected } : {}),
245
+ })
246
+ })
247
+
248
+ /**
249
+ * The source named by a `claimed_by_other_source` refusal, when it is one this workspace has
250
+ * connected. Absent when it is not: offering to switch to a source the picker cannot select
251
+ * would be a dead end, and the refusal copy alone already names what the link is.
252
+ */
253
+ const refSwitchTarget = computed(() => {
254
+ const state = refState.value
255
+ if (state.status !== 'rejected' || !state.claimedBy) return undefined
256
+ return documents.connectedSources.find((s) => s.source === state.claimedBy)
257
+ })
258
+
259
+ function switchToClaimingSource() {
260
+ const target = refSwitchTarget.value
261
+ if (target) source.value = target.source
262
+ }
263
+
264
+ /**
265
+ * Whether a line about the pasted reference is being rendered under the input. It is what keeps the
266
+ * dropdown TOTAL: every state now shows a row, a line, or the empty state. Keying the empty state
267
+ * on `status === 'none'` alone left a hole (a resolved reference the caller had already staged
268
+ * suppressed the row AND the empty state) that rendered as a blank panel explaining nothing.
269
+ */
270
+ const refNotice = computed(
271
+ () =>
272
+ refState.value.status !== 'none' &&
273
+ (refState.value.status !== 'ok' || refAlreadyAttached.value),
274
+ )
275
+
123
276
  const empty = computed(
124
277
  () =>
125
278
  !searching.value &&
126
279
  !searchError.value &&
127
280
  importedRows.value.length === 0 &&
128
281
  searchRows.value.length === 0 &&
129
- refRow.value === null,
282
+ refRow.value === null &&
283
+ !refNotice.value,
130
284
  )
131
285
 
132
286
  function pickImported(externalId: string, title: string, excerpt: string) {
@@ -154,16 +308,26 @@ function pickSearch(r: DocumentSearchResult) {
154
308
  })
155
309
  }
156
310
 
157
- function pickRef(q: string) {
158
- if (!source.value) return
311
+ /**
312
+ * Stage a reference. The canonical external id is staged, never the pasted text, so the item the
313
+ * host commits is the one the pre-flight judged. A reference the workspace has already imported
314
+ * skips the re-fetch (`needsImport: false`) and carries its real title.
315
+ *
316
+ * An UNJUDGED reference (the resolve call itself failed) carries the pasted text against the
317
+ * SELECTED source, which is what the picker did before any pre-flight existed: the import will
318
+ * judge it, and a source outage is not a reason to make attaching impossible.
319
+ */
320
+ function pickRef(row: NonNullable<typeof refRow.value>) {
321
+ const src = row.source ?? source.value
322
+ if (!src) return
159
323
  emit('pick', {
160
324
  kind: 'document',
161
- source: source.value,
162
- externalId: q,
163
- title: q,
164
- subtitle: descriptor.value?.label,
325
+ source: src,
326
+ externalId: row.externalId,
327
+ title: row.label,
328
+ subtitle: row.canonicalUrl ?? descriptor.value?.label,
165
329
  icon: icon.value,
166
- needsImport: true,
330
+ needsImport: !row.imported,
167
331
  })
168
332
  query.value = ''
169
333
  }
@@ -212,6 +376,50 @@ onMounted(() => {
212
376
  {{ t('documents.picker.searchFailed', { error: searchError }) }}
213
377
  </p>
214
378
 
379
+ <!-- The pre-flight's verdict on a pasted ref. A refusal is stated HERE, under the input
380
+ the user can still edit, rather than as a toast after the task is created. -->
381
+ <p
382
+ v-if="refState.status === 'checking'"
383
+ class="px-1 text-[11px] text-slate-500"
384
+ data-testid="doc-ref-checking"
385
+ >
386
+ {{ t('documents.picker.refChecking') }}
387
+ </p>
388
+ <div
389
+ v-else-if="refRejection"
390
+ class="flex flex-wrap items-center gap-x-2 gap-y-1 px-1 text-[11px] text-amber-400"
391
+ data-testid="doc-ref-rejected"
392
+ >
393
+ <span>{{ refRejection }}</span>
394
+ <UButton
395
+ v-if="refSwitchTarget"
396
+ color="neutral"
397
+ variant="link"
398
+ size="xs"
399
+ class="p-0"
400
+ data-testid="doc-ref-switch-source"
401
+ @click="switchToClaimingSource"
402
+ >
403
+ {{ t('documents.picker.refSwitchSource', { source: refSwitchTarget.label }) }}
404
+ </UButton>
405
+ </div>
406
+ <p
407
+ v-else-if="refState.status === 'unchecked'"
408
+ class="px-1 text-[11px] text-amber-400"
409
+ data-testid="doc-ref-unchecked"
410
+ >
411
+ {{ t('documents.picker.refCheckFailed', { error: refState.message }) }}
412
+ </p>
413
+ <!-- Already staged, so the row is suppressed. Stated, because a paste that produces nothing
414
+ at all reads as a picker that dropped it. -->
415
+ <p
416
+ v-else-if="refAlreadyAttached"
417
+ class="px-1 text-[11px] text-slate-500"
418
+ data-testid="doc-ref-already-attached"
419
+ >
420
+ {{ t('documents.picker.refAlreadyAttached') }}
421
+ </p>
422
+
215
423
  <div class="max-h-56 space-y-0.5 overflow-y-auto">
216
424
  <!-- Already-imported documents (linked directly, no re-fetch). -->
217
425
  <button
@@ -240,21 +448,48 @@ onMounted(() => {
240
448
  <span class="truncate">{{ r.title }}</span>
241
449
  </button>
242
450
 
243
- <!-- Explicit URL/ID reference (imported on add). -->
451
+ <!-- Explicit URL/ID reference, RESOLVED: the row shows the canonical form the source
452
+ settled on, so a share link's title segment and tracking params are visibly gone
453
+ before the attachment is staged. -->
244
454
  <button
245
455
  v-if="refRow"
246
456
  type="button"
247
- class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
457
+ class="flex w-full items-start gap-1.5 rounded-md px-2 py-1.5 text-start text-xs text-slate-300 hover:bg-slate-800/70"
458
+ data-testid="doc-ref-attach"
248
459
  @click="pickRef(refRow)"
249
460
  >
250
- <UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
251
- <span class="truncate">
252
- <i18n-t keypath="documents.picker.attachByReference" scope="global">
253
- <template #ref>
254
- <span class="text-slate-200">{{ refRow }}</span>
255
- </template>
256
- </i18n-t>
461
+ <UIcon name="i-lucide-link" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400" />
462
+ <span class="min-w-0">
463
+ <span class="block truncate">
464
+ <i18n-t keypath="documents.picker.attachByReference" scope="global">
465
+ <template #ref>
466
+ <span class="text-slate-200">{{ refRow.label }}</span>
467
+ </template>
468
+ </i18n-t>
469
+ </span>
470
+ <span v-if="refRow.trimmed" class="block truncate text-[11px] text-slate-500">
471
+ {{ t('documents.picker.refTrimmed') }}
472
+ </span>
473
+ <!-- A WIDENED reference, which the trim note above must never be left to imply: the
474
+ frame this link named could not be read, so what gets attached is everything
475
+ around it. Amber and separate, because it is a loss rather than tidying. -->
476
+ <span
477
+ v-if="refRow.droppedScope"
478
+ class="block text-[11px] text-amber-400"
479
+ data-testid="doc-ref-widened"
480
+ >
481
+ {{ t('documents.picker.refWidened', { scope: refRow.droppedScope }) }}
482
+ </span>
257
483
  </span>
484
+ <UBadge
485
+ v-if="refRow.imported"
486
+ color="neutral"
487
+ variant="soft"
488
+ size="xs"
489
+ class="ms-auto shrink-0"
490
+ >
491
+ {{ t('documents.picker.importedBadge') }}
492
+ </UBadge>
258
493
  </button>
259
494
 
260
495
  <EmptyState
@@ -2,7 +2,7 @@
2
2
  // The single rendering path for an agent kind's icon (+ optional label) anywhere a
3
3
  // pipeline or run lists its steps. Resolves display metadata through
4
4
  // `agentKindMeta`, which is total over every kind — palette archetypes, custom
5
- // agents and the engine's system kinds (ci / merger / blueprints / conflicts) — so
5
+ // agents and the engine's system kinds (ci / merger / conflicts) — so
6
6
  // a saved pipeline that contains a system kind can never blow up the renderer.
7
7
  import { computed } from 'vue'
8
8
  import { agentKindMeta } from '~/utils/catalog'
@@ -56,8 +56,8 @@ const busy = ref(false)
56
56
  const filter = ref('')
57
57
 
58
58
  // The palette archetypes PLUS the engine-driven kinds that still run an LLM
59
- // (spec-writer, merger, the fixers/resolver). The pure gates run no model, so they
60
- // stay out — exactly the set the per-agent override list should cover.
59
+ // (merger, the fixers/resolver). The pure gates run no model, so they stay out —
60
+ // exactly the set the per-agent override list should cover.
61
61
  const configurableKinds = computed(() => [...agents.archetypes, ...MODEL_CONFIGURABLE_SYSTEM_KINDS])
62
62
 
63
63
  // Narrowed to the selected agent tier, EXCEPT that a kind the preset being edited already
@@ -9,6 +9,7 @@ import {
9
9
  listDocumentsContract,
10
10
  listDocumentSourcesContract,
11
11
  planDocumentContract,
12
+ resolveDocumentRefContract,
12
13
  searchDocumentsContract,
13
14
  spawnDocumentContract,
14
15
  unlinkDocumentForKindContract,
@@ -48,6 +49,15 @@ export function documentsApi({ send, ws }: ApiContext) {
48
49
  listDocuments: (workspaceId: string) =>
49
50
  send(listDocumentsContract, { pathPrefix: ws(workspaceId) }),
50
51
 
52
+ // Canonicalise a pasted URL/id without importing it: the pre-flight the attach pickers run
53
+ // so an unusable link is corrected before the task that carries it is saved.
54
+ resolveDocumentRef: (workspaceId: string, source: DocumentSourceKind, body: { ref: string }) =>
55
+ send(resolveDocumentRefContract, {
56
+ pathPrefix: ws(workspaceId),
57
+ pathParams: { source },
58
+ body,
59
+ }),
60
+
51
61
  importDocument: (workspaceId: string, source: DocumentSourceKind, body: { ref: string }) =>
52
62
  send(importDocumentContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
53
63