fix(desktop): a sent reference renders as the chip the composer showed

A reference whose value is backtick-quoted — `@url:` always, and any path
with a space — arrived in the sent bubble as a bare `@url:` followed by a
markdown code span. The composer showed a chip; sending it produced two
wrong things.

user-message-text scans inline code BEFORE handing the remaining text to
DirectiveContent, so it claimed the directive's quoting as a code span and
split the reference down the middle. Directives win that overlap: the
backticks are syntax the composer wrote, not something the user typed as
code.

The pattern itself lived in three identical copies (composer hydration,
sent bubble, and the one this fix needed), plus a fourth copy of the kind
list. They agreed today by luck. reference-kinds already owns what a
reference LOOKS like, so it now also owns what one IS: WIRE_REFERENCE_KINDS
and referenceRe(), a fresh matcher per call because a shared /g regex
carries lastIndex between callers.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 07:15:44 -05:00
parent 14db1a99e2
commit 0c4a5d70f5
5 changed files with 118 additions and 14 deletions

View file

@ -15,13 +15,15 @@ import {
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
import { referenceKind } from '@/components/assistant-ui/reference-kinds'
import { referenceKind, referenceRe } from '@/components/assistant-ui/reference-kinds'
import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
/** @see referenceRe the shared pattern every surface recognises a reference
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
export const REF_RE = referenceRe()
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }

View file

@ -13,9 +13,9 @@ import { useSessionLinkTitle } from '@/lib/session-link-title'
import { parseSessionRefValue, sessionRefFallbackLabel } from '@/lib/session-refs'
import { cn } from '@/lib/utils'
import { referenceKind, referenceStyle } from './reference-kinds'
import { referenceKind, referenceRe, referenceStyle, WIRE_REFERENCE_KINDS } from './reference-kinds'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
const HERMES_REF_TYPES = WIRE_REFERENCE_KINDS
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
/** Icon glyphs come from the shared reference vocabulary, so the popover row
@ -116,15 +116,7 @@ const DirectiveIcon: FC<{ type: string; className?: string }> = ({ type, classNa
*/
const CANONICAL_DIRECTIVE_RE = /:([\w-]{1,64})\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/g
const HERMES_DIRECTIVE_RE = new RegExp(
'@(file|folder|url|image|tool|line|terminal|session):(' +
'`[^`\\n]+`' +
'|"[^"\\n]+"' +
"|'[^'\\n]+'" +
'|\\S+' +
')',
'g'
)
const HERMES_DIRECTIVE_RE = referenceRe()
// A skill referenced in a sent message — either the invocation that opens it
// (`/work fix the leak`, which is all a skill turn ever renders as) or one

View file

@ -129,3 +129,31 @@ export function referenceKind(type: string | undefined): ReferenceKind {
export function referenceStyle(type: string | undefined): ReferenceStyle {
return REFERENCE_STYLES[referenceKind(type)]
}
/**
* The kinds that travel in message text as `@kind:value`. A subset of the table
* above: `command`/`skill`/`theme` arrive via `/`, and `diff`/`staged`/`emoji`
* have no value to carry.
*/
export const WIRE_REFERENCE_KINDS = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
/**
* The one pattern that recognises a reference in text.
*
* A value is quoted whenever it needs to be `@url:` always, and any path with
* a space so the quoted forms are tried BEFORE bare `\S+`, or a quoted value
* would end at the first space and strand the rest as prose.
*/
const REFERENCE_PATTERN = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/
/**
* A fresh matcher for every surface that has to find references in text: the
* composer hydrating a draft, the sent bubble, the edit composer.
*
* New instance per call on purpose a shared `/g` regex carries `lastIndex`
* between callers, which is how a scanner silently skips the first reference in
* the next string it's handed.
*/
export function referenceRe(): RegExp {
return new RegExp(REFERENCE_PATTERN.source, 'g')
}

View file

@ -0,0 +1,59 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { referenceRe, WIRE_REFERENCE_KINDS } from '@/components/assistant-ui/reference-kinds'
import { UserMessageText } from './user-message-text'
afterEach(cleanup)
/**
* A sent reference must render as the chip the composer showed. These cover the
* seam where that used to break: the value's quoting is directive syntax, and a
* surface that reads it as markdown splits one reference into two wrong things.
*/
describe('a sent reference renders as the chip the composer showed', () => {
it('chips a backtick-quoted @url: instead of splitting it into code', () => {
render(<UserMessageText text="@url:`https://github.com/NousResearch/hermes-agent/pull/74790` urls lose formatting" />)
expect(screen.queryByTitle('https://github.com/NousResearch/hermes-agent/pull/74790')).not.toBeNull()
// The whole reference is one node — no bare `@url:` text left behind.
expect(document.body.textContent).not.toContain('@url:')
})
it('chips a backtick-quoted @file: path with spaces', () => {
render(<UserMessageText text="see @file:`apps/desktop/my notes.md` please" />)
expect(screen.queryByTitle('apps/desktop/my notes.md')).not.toBeNull()
expect(document.body.textContent).not.toContain('@file:')
})
it('chips every kind that travels in message text', () => {
// The guard against WIRE_REFERENCE_KINDS and the pattern's own alternation
// drifting apart: add a kind to one and this fails until both agree.
for (const kind of WIRE_REFERENCE_KINDS) {
expect(`@${kind}:\`some value\``.match(referenceRe()), kind).toHaveLength(1)
}
})
it('still renders a genuine code span as code', () => {
render(<UserMessageText text="run `npm test` first" />)
const code = document.querySelector('[data-slot="aui_user-inline-code"]')
expect(code?.textContent).toBe('npm test')
})
it('renders code and a reference side by side', () => {
render(<UserMessageText text="run `npm test` on @file:`apps/desktop/a b.ts` now" />)
expect(document.querySelector('[data-slot="aui_user-inline-code"]')?.textContent).toBe('npm test')
expect(screen.queryByTitle('apps/desktop/a b.ts')).not.toBeNull()
})
it('leaves a fenced block alone', () => {
render(<UserMessageText text={'before\n```ts\nconst x = 1\n```\nafter'} />)
expect(document.querySelector('[data-slot="aui_user-fence"]')?.textContent).toBe('const x = 1\n')
})
})

View file

@ -2,6 +2,7 @@ import type { FC } from 'react'
import { Fragment, useMemo } from 'react'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { referenceRe } from '@/components/assistant-ui/reference-kinds'
import { cn } from '@/lib/utils'
// User messages should render the bare-minimum of markdown: backtick `code`
@ -42,6 +43,28 @@ const FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g
// Greedy backtick run length so ``code with `backticks` inside`` works.
const INLINE_CODE_RE = /(`+)([^`\n][\s\S]*?)\1/g
// A directive's value is BACKTICK-QUOTED whenever it needs to be (`@url:`
// always, and any path with a space), so the inline-code scanner would claim
// those backticks first and split one reference into a bare `@url:` plus a code
// span — the composer's chip, flattened on send. Directives win: this is syntax
// the composer wrote, not something the user typed as code.
/** Inline-code matches that don't overlap a directive, so a quoted directive
* value reaches DirectiveContent whole. */
function inlineCodeOutsideDirectives(text: string): RegExpMatchArray[] {
const directives = Array.from(text.matchAll(referenceRe())).map(match => ({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length
}))
return Array.from(text.matchAll(INLINE_CODE_RE)).filter(match => {
const start = match.index ?? 0
const end = start + match[0].length
return !directives.some(directive => start < directive.end && end > directive.start)
})
}
function splitFences(text: string): TopSegment[] {
const segments: TopSegment[] = []
let cursor = 0
@ -72,7 +95,7 @@ function splitInlineCode(text: string): InlineNode[] {
const nodes: InlineNode[] = []
let cursor = 0
for (const match of text.matchAll(INLINE_CODE_RE)) {
for (const match of inlineCodeOutsideDirectives(text)) {
const start = match.index ?? 0
if (start > cursor) {