a mention renders as a pill, and the id stops being visible

@Todo Guy [6305e570] read as literal text in the bubble, brackets and all,
which is not what a reference should look like.

it renders as a LINK node at #officer-mention-<id>, and the bubble's existing
`a` override turns that into a pill. a span with a class was the obvious thing
and does not survive — rehypeSanitize strips exactly that. a fragment href
passes untouched. inlineCode also survives and was dropped because it cannot be
told apart from somebody typing backticks around an @; only a real mention
should look like one.

it is a span rather than an anchor: there is nowhere to navigate to yet, and
opening the referenced conversation is a real feature with its own decisions.
it should not pretend to be clickable.

the pill reads @Todo Guy. the [6305e570] stays in the text that reaches the
agent, which is the entire reason it is there. discord does the same — a human
reads a name, the machine reads an id.

hand-rolled tree walk: installs are frozen and neither unist-util-visit nor
@types/mdast is in the lockfile, so a nine-line walk does not get to change it.
node shapes declared locally, only the fields this touches.

tested against over-matching, which is the failure that would matter: an email
address, a bracketed price, a non-hex id. eight hex characters is the guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 06:52:20 +00:00
co-authored by Claude Opus 5
parent b5385ff40f
commit 2faf1fa4b2
3 changed files with 154 additions and 9 deletions
@@ -1,6 +1,7 @@
import type { ComponentProps } from 'react';
import type { ReactNode, ComponentProps } from 'react';
import { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { remarkMentions, MENTION_HREF } from './remark-mentions';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
@@ -49,12 +50,27 @@ import { useTranslation } from '../../../locales';
* `noopener noreferrer` is not decoration — a bare `target="_blank"` hands the opened page a live
* `window.opener` back into the app, which is a redirect away from anything an agent linked to.
*/
const ChatLink = ({ href, children, ...rest }: ComponentProps<'a'>) => (
<a {...rest} href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
/**
* A mention, rendered as a pill.
*
* `remarkMentions` turns `@Todo Guy [6305e570]` into a link at `#officer-mention-<id>`, and this is where
* that becomes visible. Not an anchor: there is nowhere to navigate to yet — opening the referenced
* conversation would be a real feature with its own decisions — so it renders as a `<span>` and does not
* pretend to be clickable.
*/
const Mention = ({ children }: { children?: ReactNode }) => (
<span className="rounded bg-primary/15 px-1 py-0.5 font-medium text-primary">{children}</span>
);
const ChatLink = ({ href, children, ...rest }: ComponentProps<'a'>) => {
if (href?.startsWith(MENTION_HREF)) return <Mention>{children}</Mention>;
return (
<a {...rest} href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
};
const MD_COMPONENTS = { pre: CodeBlock, img: ChatImage, a: ChatLink };
const sanitizeSchema = {
@@ -186,7 +202,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
base64 blob pushes the bubble past max-w and out of the pane. */}
<div className="chat-md break-words">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkMentions]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -232,7 +248,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
<div className="chat-md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkMentions]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -530,7 +546,7 @@ const PlanCard = ({
</div>
<div className="max-h-96 overflow-y-auto break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MD_COMPONENTS}>
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions]} components={MD_COMPONENTS}>
{message.plan}
</ReactMarkdown>
</div>
@@ -621,7 +637,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
{text ? (
<div className="chat-md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkMentions]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
components={MD_COMPONENTS}
>
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test';
import { splitMentions, MENTION_HREF } from './remark-mentions';
/**
* What becomes a pill and what stays as text.
*
* The failure that matters is over-matching: an email address, a price in brackets, or somebody quoting
* this syntax should not turn into a reference to a conversation they did not mean.
*/
describe('splitMentions', () => {
test('a mention on its own', () => {
expect(splitMentions('@Todo Guy [6305e570]')).toEqual([
{ type: 'link', url: `${MENTION_HREF}6305e570`, children: [{ type: 'text', value: '@Todo Guy' }] },
]);
});
test('keeps the text around it', () => {
const parts = splitMentions('ask @Todo Guy [6305e570] about it');
expect(parts?.map((p) => p.type)).toEqual(['text', 'link', 'text']);
expect((parts?.[0] as { value: string }).value).toBe('ask ');
expect((parts?.[2] as { value: string }).value).toBe(' about it');
});
test('two in one line', () => {
const parts = splitMentions('@A [aaaaaaaa] and @B [bbbbbbbb]');
expect(parts?.filter((p) => p.type === 'link')).toHaveLength(2);
});
test('no mention, no work', () => {
expect(splitMentions('nothing here')).toBeNull();
expect(splitMentions('mail pastilhas@officer.dev about it')).toBeNull();
});
// The id shape is the guard against over-matching: eight hex characters, not any bracketed thing.
test('a bracketed something else is not a mention', () => {
expect(splitMentions('the cost @ checkout [about 40]')).toBeNull();
expect(splitMentions('@name [not-hex-x]')).toBeNull();
});
test('the displayed label drops the id', () => {
const parts = splitMentions('@Platform Deployer [944584e3]');
expect((parts?.[0] as { children: { value: string }[] }).children[0]!.value).toBe('@Platform Deployer');
});
});
@@ -0,0 +1,84 @@
/**
* Render `@Todo Guy [6305e570]` as a mention rather than as literal text.
*
* ── Why a link node and not raw HTML ──
*
* A `<span class="mention">` would be the obvious thing and it does not survive: the bubble runs
* `rehypeSanitize`, and a class on an arbitrary span is exactly what that strips. Emitting a LINK with a
* fragment `href` passes untouched — fragments are permitted by the default schema — and the bubble
* already overrides `a`, so the pill is rendered there.
*
* `inlineCode` was the other candidate. It also survives sanitising, and it was dropped because it cannot
* be told apart from somebody typing backticks around an `@`. Only a real mention should look like one.
*
* ── The id disappears from view, not from the message ──
*
* The pill reads `@Todo Guy`. The `[6305e570]` stays in the text that reaches the agent, which is the
* entire reason it is there — it is the part that makes the reference resolvable. Discord does the same:
* a human reads a name, the machine reads an id.
*
* ── Hand-rolled walk, no `unist-util-visit` ──
*
* Installs are frozen (`bunfig.toml`), and neither that package nor `@types/mdast` is in the lockfile.
* Adding one for a nine-line tree walk would mean a deliberate lockfile change for no benefit, so the
* node shapes are declared locally — only the three fields this touches.
*/
type MdText = { type: 'text'; value: string };
type MdLink = { type: 'link'; url: string; children: MdText[] };
type MdNode = { type: string; value?: string; children?: MdNode[] };
/** `@` then a name, then a bracketed short id. The name may contain spaces — most of them do. */
const MENTION = /@([^[\n]+?)\s*\[([0-9a-f]{8})\]/g;
export const MENTION_HREF = '#officer-mention-';
/** Split one text node into text and link nodes, or return null when it holds no mention. */
export function splitMentions(value: string): (MdText | MdLink)[] | null {
if (!value.includes('@')) return null;
const parts: (MdText | MdLink)[] = [];
let last = 0;
// A fresh iterator per call — the regex is global, and a shared `lastIndex` would skip matches in every
// second node it was used on.
for (const m of value.matchAll(MENTION)) {
const start = m.index ?? 0;
if (start > last) parts.push({ type: 'text', value: value.slice(last, start) });
parts.push({
type: 'link',
url: `${MENTION_HREF}${m[2]}`,
children: [{ type: 'text', value: `@${m[1]!.trim()}` }],
});
last = start + m[0].length;
}
if (parts.length === 0) return null;
if (last < value.length) parts.push({ type: 'text', value: value.slice(last) });
return parts;
}
export function remarkMentions() {
return (tree: MdNode) => {
const walk = (node: MdNode): void => {
if (!node.children) return;
const next: MdNode[] = [];
for (const child of node.children) {
if (child.type === 'text' && typeof child.value === 'string') {
const split = splitMentions(child.value);
if (split) {
next.push(...(split as unknown as MdNode[]));
continue;
}
}
// Not descended into for a link: a mention inside a link is somebody's URL text, not a mention.
if (child.type !== 'link' && child.type !== 'inlineCode' && child.type !== 'code') walk(child);
next.push(child);
}
node.children = next;
};
walk(tree);
};
}