diff --git a/frontend/src/v2/__tests__/V2MessageRow.test.tsx b/frontend/src/v2/__tests__/V2MessageRow.test.tsx index 77e3517a35..6addf7ea7f 100644 --- a/frontend/src/v2/__tests__/V2MessageRow.test.tsx +++ b/frontend/src/v2/__tests__/V2MessageRow.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import V2MessageRow from '../components/V2MessageRow'; +import V2MessageRow, { linkifyBareUrls, messageMarkdownComponents } from '../components/V2MessageRow'; jest.mock('../../context/AuthContext', () => ({ useAuth: () => ({ currentUser: { username: 'viewer' } }), @@ -14,6 +14,24 @@ describe('V2MessageRow', () => { process.env.REACT_APP_API_URL = previousApiUrl; }); + it('links a complete bare URL, keeps trailing punctuation outside, and leaves markdown links alone', () => { + expect(linkifyBareUrls('Full notes: https://docs.example.com/launch/guide. Then [open the pod](https://commonly.me/v2/pods/abc).')) + .toBe('Full notes: [https://docs.example.com/launch/guide](https://docs.example.com/launch/guide). Then [open the pod](https://commonly.me/v2/pods/abc).'); + expect(linkifyBareUrls('See https://commonly.me/v2/pods/abc, then https://mail.example.com/a:b.')) + .toBe('See [https://commonly.me/v2/pods/abc](https://commonly.me/v2/pods/abc), then [https://mail.example.com/a:b](https://mail.example.com/a:b).'); + }); + + it('does not pass markdown parser nodes to link or code block DOM elements', () => { + const { container } = render( + + {React.createElement(messageMarkdownComponents.a, { href: 'https://docs.example.com', node: {} }, 'docs')} + {React.createElement(messageMarkdownComponents.pre, { node: {} }, echo hello)} + , + ); + expect(container.querySelector('a')).not.toHaveAttribute('node'); + expect(container.querySelector('pre')).not.toHaveAttribute('node'); + }); + it('keeps the runtime beside the agent name before its timestamp', () => { render( diff --git a/frontend/src/v2/components/V2MessageRow.tsx b/frontend/src/v2/components/V2MessageRow.tsx index d7cea411a9..e31fcc0498 100644 --- a/frontend/src/v2/components/V2MessageRow.tsx +++ b/frontend/src/v2/components/V2MessageRow.tsx @@ -26,6 +26,13 @@ import { messageImageUrl } from '../utils/messageImageUrl'; // match in `split()` output so we can render text + pill segments in order. const MENTION_RE = /(@[a-zA-Z0-9][a-zA-Z0-9_-]*)/g; +// Markdown does not link raw URLs. Keep terminal sentence punctuation outside +// the link while consuming the full dotted host and path. +export const linkifyBareUrls = (content: string): string => content.replace( + /(?"]+[^\s<>".,!?;:])(?=[\s.,!?;:]|$)/g, + '[$1]($1)', +); + // Walk a React children tree and replace bare `@name` text segments with a // styled `` pill. Recurses into arrays + cloned elements so mentions // inside ``, ``, list items, etc. still render correctly. Code @@ -69,9 +76,9 @@ export const MessageMarkdownImage = ({ src, alt }: React.ImgHTMLAttributes{alt || src}; }; -const messageMarkdownComponents = { +export const messageMarkdownComponents = { img: MessageMarkdownImage, - a: ({ children, ...props }: React.AnchorHTMLAttributes) => ( + a: ({ children, node: _node, ...props }: React.AnchorHTMLAttributes & { node?: unknown }) => ( {renderWithMentions(children)} ), p: ({ children }: React.HTMLAttributes) => ( @@ -84,7 +91,7 @@ const messageMarkdownComponents = { // `
`. Both fall through to v2.css selectors `.v2-msg__content code`
   // and `.v2-msg__content pre`. Mentions inside code are NOT transformed.
   // Fenced code past six lines collapses (direction C).
-  pre: (props: React.HTMLAttributes) => ,
+  pre: ({ node: _node, ...props }: React.HTMLAttributes & { node?: unknown }) => ,
 };
 
 interface V2MessageRowProps {
@@ -522,10 +529,7 @@ const V2MessageRow: React.FC = ({ message, sourceMessageId, d
   // syntax. Without this, agents posting raw URLs render as plain text and
   // the user can't click them.
   if (stripped && !externalBareImage) {
-    stripped = stripped.replace(
-      /(?"]+?[^\s<>".,!?;:])(?=[\s.,!?;:]|$)/g,
-      '[$1]($1)',
-    );
+    stripped = linkifyBareUrls(stripped);
   }
 
   // Highlight messages that @-mention the current user. Word-boundary so