From 2faf1fa4b2de9a309939ec45b60b92abf2142006 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 26 Aug 2026 06:52:20 +0000 Subject: [PATCH] a mention renders as a pill, and the id stops being visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @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-, 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 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .../apps/Chat/components/MessageBubble.tsx | 34 ++++++-- .../Chat/components/remark-mentions.test.ts | 45 ++++++++++ .../apps/Chat/components/remark-mentions.ts | 84 +++++++++++++++++++ 3 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.test.ts create mode 100644 src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.ts diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index 4500cf77..25d1fef0 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -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'>) => ( - - {children} - +/** + * A mention, rendered as a pill. + * + * `remarkMentions` turns `@Todo Guy [6305e570]` into a link at `#officer-mention-`, 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 `` and does not + * pretend to be clickable. + */ +const Mention = ({ children }: { children?: ReactNode }) => ( + {children} ); +const ChatLink = ({ href, children, ...rest }: ComponentProps<'a'>) => { + if (href?.startsWith(MENTION_HREF)) return {children}; + return ( + + {children} + + ); +}; + 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. */}
@@ -232,7 +248,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
@@ -530,7 +546,7 @@ const PlanCard = ({
- + {message.plan}
@@ -621,7 +637,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => { {text ? (
diff --git a/src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.test.ts b/src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.test.ts new file mode 100644 index 00000000..374c9ad6 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.test.ts @@ -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'); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.ts b/src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.ts new file mode 100644 index 00000000..b72f48f0 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/remark-mentions.ts @@ -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 `` 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); + }; +}