Merge pull request #74806 from NousResearch/bb/ref-parity

A sent reference renders as the chip the composer showed
This commit is contained in:
brooklyn! 2026-07-30 07:32:22 -05:00 committed by GitHub
commit 466e6402f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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) {