diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 6285a4dcfd0..77fc3474af8 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 3846b4fc5df..6d149e2dd2d 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -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 diff --git a/apps/desktop/src/components/assistant-ui/reference-kinds.ts b/apps/desktop/src/components/assistant-ui/reference-kinds.ts index a12f5cc6fb7..453d05411d2 100644 --- a/apps/desktop/src/components/assistant-ui/reference-kinds.ts +++ b/apps/desktop/src/components/assistant-ui/reference-kinds.ts @@ -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') +} diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-text.test.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-text.test.tsx new file mode 100644 index 00000000000..7f1feef89eb --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-text.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + expect(document.querySelector('[data-slot="aui_user-fence"]')?.textContent).toBe('const x = 1\n') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx b/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx index cd0bdf1f631..15f46495f13 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-message-text.tsx @@ -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) {