@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
|
@@ -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
|
-
|
|
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
|
|
58
|
-
timer = setTimeout(
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
111
|
-
if (!
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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:
|
|
162
|
-
externalId:
|
|
163
|
-
title:
|
|
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:
|
|
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
|
|
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-
|
|
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="
|
|
252
|
-
<
|
|
253
|
-
<
|
|
254
|
-
<
|
|
255
|
-
|
|
256
|
-
|
|
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 /
|
|
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
|
-
// (
|
|
60
|
-
//
|
|
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
|
|