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:
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user