fix(ui-conversation): decorate pasted file paths as whole tokens

Closed @"…" paths and unquoted @ tokens with a separator or extension now decorate in full, matching the spans the sent message renders as references.
This commit is contained in:
Turtle committed 2026-10-26 16:33:21 +08:00
1 parent c403ca4d77
commit deebbf3191
3 files changed
+40 -26

No files matched your search

@@ -2,7 +2,7 @@
* Plain-text reference scan (the plain-text-reference decision;
* see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* a `/name` or `@name` token whose name is on the trigger's lexicon, and
* syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's
* syntax-recognizable `@path` file and folder tokens. Pure derivation — the editor's
* text-ref entity transform consumes these ranges; editing the text out of
* match shape simply drops the range next scan.
*/
@@ -10,9 +10,9 @@
/**
* One plain-text reference range (the plain-text-reference decision;
* see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
* a `/name` or `@name` token
* whose name is on the trigger's lexicon. Pure derivation — editing the text
* out of match shape simply drops the range next scan.
* a `/name` or `@name` token whose name is on the trigger's lexicon, or an
* `@path` file or folder token. Pure derivation — editing the text out of
* match shape simply drops the range next scan.
*/
export interface TextRefRange {
readonly start: number
@@ -23,30 +23,40 @@ export interface TextRefRange {
/** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */
const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g
/** One `@` path token: a quoted path through its closing quote or line end, or an unquoted run. */
const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]+))/g
const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]\S*))/g
/**
* What may follow an unquoted or open-quoted folder token's slash: trailing
* punctuation, then whitespace or the draft end. The class matches the
* punctuation that `folderReferenceRule` in ui-reference strips from a sent token.
* Punctuation that ends prose rather than a path: the characters the
* sent-message file and folder rules in ui-reference strip from the end of an
* unquoted token.
*/
const FOLDER_END_RE = /[.,;:!?,。;:!?]*(?:\s|$)/y
const TRAILING_PUNCTUATION = new Set('.,;:!?,。;:!?')
/** An unquoted token names a path only with a separator or an extension; a bare `@name` stays plain until a lexicon claims it. */
const PATH_LIKE_RE = /^@.*[/\\.]/u
/**
* End of the whole `@dir/` folder token at `start`, so `@src/a.ts` and
* `@"a/b c.md"` never match their directory prefix. A closed quote ends the
* token when it directly follows the slash; an unquoted or open-quoted token
* (a drill in progress) ends at its last slash.
* End of the whole `@path` file or folder token at `start`, so a path is never
* cut at its directory prefix. A closed quote ends at its quote and an
* unquoted token with a separator or extension drops trailing punctuation,
* the spans the sent-message rules render. An open quote (a folder drill in
* progress) ends at its last slash when only trailing punctuation and
* whitespace or the draft end follow it.
* @param draft - draft text.
* @param start - offset of the token's `@`.
* @param token - the path token at `start`.
* @returns the folder token's end offset, or undefined for a non-folder token.
* @returns the token's end offset, or undefined when it names no path.
*/
function folderEnd(draft: string, start: number, token: string): number | undefined {
if (token.startsWith('@"') && token.endsWith('/"')) return start + token.length
function pathEnd(draft: string, start: number, token: string): number | undefined {
if (!token.startsWith('@"')) {
let length = token.length
while (TRAILING_PUNCTUATION.has(token.charAt(length - 1))) length -= 1
return PATH_LIKE_RE.test(token.slice(0, length)) ? start + length : undefined
}
if (token.length > 3 && token.endsWith('"')) return start + token.length
const slash = token.lastIndexOf('/')
if (slash < 0 || token.endsWith('"')) return undefined
FOLDER_END_RE.lastIndex = start + slash + 1
return FOLDER_END_RE.test(draft) ? start + slash + 1 : undefined
if (slash < 0) return undefined
let next = start + slash + 1
while (TRAILING_PUNCTUATION.has(draft.charAt(next))) next += 1
return next === draft.length || /\s/u.test(draft.charAt(next)) ? start + slash + 1 : undefined
}
/**
* What may follow a `/name` token: whitespace or the draft end, the boundary
@@ -85,7 +95,7 @@ export function scanTextRefs(
let path: RegExpExecArray | null
while ((path = PATH_TOKEN_RE.exec(draft)) !== null) {
const start = path.index + (path[1]?.length ?? 0)
const end = folderEnd(draft, start, path[2] ?? '')
const end = pathEnd(draft, start, path[2] ?? '')
if (end !== undefined && !out.some(range => range.start < end && range.end > start)) {
out.push({ start, end, trigger: '@' })
}
@@ -812,10 +812,14 @@ describe('plain-text reference catalogs', () => {
])
})
it('matches whole folder tokens, never the directory prefix of a pasted file path', () => {
expect(scanTextRefs('@src/deep/file.txt @"/a/nv/SM100 ISA.html" 看 @"a/ b.md" @src/.env @src/de', new Map())).toEqual([])
const text = '@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x'
expect(scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end)))
it('matches whole file and folder path tokens, never a directory prefix', () => {
const slices = (text: string) => scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end))
expect(slices('@src/deep/file.txt, @"/a/nv/SM100 ISA.html" 看 @"a/ b.md"。 @src/.env @C:\\src\\a.ts @src/de'))
.toEqual(['@src/deep/file.txt', '@"/a/nv/SM100 ISA.html"', '@"a/ b.md"', '@src/.env', '@C:\\src\\a.ts', '@src/de'])
expect(slices('@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x'))
.toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"src/"', '@"open dir/'])
expect(slices('@person @"" @"a/b')).toEqual([])
expect(slices('@"open @')).toEqual([])
expect(slices('drill @"open dir/。')).toEqual(['@"open dir/'])
})
})
@@ -1875,13 +1875,13 @@ describe('decorations', () => {
expect(shell.snapshot.draft).toBe('see @src/components/')
})
it('a pasted file path stays plain: only a whole folder token decorates', async () => {
it('a pasted path decorates as one whole file or folder token', async () => {
const { view, shell, textarea } = bench()
const marks = () => [...view.container.querySelectorAll('[data-composer-text-ref]')].map(el => el.textContent)
const text = 'read @"/a/nv/SM100 ISA.html" and @src/deep/file.txt then @src/deep/ first'
fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => text } })
await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(text) })
expect(marks()).toEqual(['@src/deep/'])
expect(marks()).toEqual(['@"/a/nv/SM100 ISA.html"', '@src/deep/file.txt', '@src/deep/'])
})
it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => {