From 0c4a5d70f5956246ae004427d45c8d9a3c9130c3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 07:15:44 -0500 Subject: [PATCH] fix(desktop): a sent reference renders as the chip the composer showed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/app/chat/composer/rich-editor.ts | 6 +- .../assistant-ui/directive-text.tsx | 14 +---- .../assistant-ui/reference-kinds.ts | 28 +++++++++ .../thread/user-message-text.test.tsx | 59 +++++++++++++++++++ .../assistant-ui/thread/user-message-text.tsx | 25 +++++++- 5 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/user-message-text.test.tsx 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) {