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