@@ -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);
+ };
+}