godcrm/components/HighlightedText.tsx
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

262 lines
8.5 KiB
TypeScript

/**
* HighlightedText Component
* ADR-116: Structured Invocation Tokens
*
* Renders text with 4 token formats:
* <<@slug>> -> MentionPill (invocation — blue pill with user icon)
* @slug -> subtle reference highlight (blue text)
* <</slug>> -> CommandPill (invocation — purple pill with bot icon)
* /slug -> subtle reference highlight (purple text)
*/
import React, { useCallback } from 'react';
import { cn } from '@/shared/utils/cn';
import { MentionPill, CommandPill } from './InvocationPills';
interface HighlightedTextProps {
/** The raw text content to render */
text: string;
/** Callback when a @mention or /command is clicked — receives the full token (e.g. "@dev-user" or "/developer") */
onMentionClick?: (token: string) => void;
/** Additional class names for the wrapper */
className?: string;
}
// Segment types for the parsed output
type Segment =
| { type: 'text'; value: string }
| { type: 'url'; value: string } // clickable URL
| { type: 'mention'; value: string } // plain @slug — subtle highlight
| { type: 'command'; value: string } // plain /slug — subtle highlight
| { type: 'mention-pill'; slug: string } // <<@slug>> — invocation pill
| { type: 'command-pill'; slug: string }; // <</slug>> — invocation pill
/**
* Parses text and returns an array of segments covering all 4 token formats.
*
* Order matters: we match structured tokens (<<...>>) first so they are not
* consumed by the plain @slug / /slug patterns.
*/
function parseSegments(text: string): Segment[] {
// Combined regex that matches all four patterns in priority order:
// 1. <<@slug>> — structured mention invocation
// 2. <</slug>> — structured command invocation
// 3. @slug — plain mention reference (after whitespace or start)
// 4. /slug — plain command reference (after whitespace or start)
const TOKEN_REGEX = /<<@([a-z0-9][a-z0-9_-]*)>>|<<\/([a-z0-9][a-z0-9_-]*)>>|(^|[\s\n])(@[a-z0-9][a-z0-9_-]*)|(^|[\s\n])(\/[a-z0-9][a-z0-9_-]*)/gim;
const segments: Segment[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = TOKEN_REGEX.exec(text)) !== null) {
const matchStart = match.index;
if (match[1] !== undefined) {
// <<@slug>> — mention pill
if (matchStart > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, matchStart) });
}
segments.push({ type: 'mention-pill', slug: match[1].toLowerCase() });
lastIndex = matchStart + match[0].length;
} else if (match[2] !== undefined) {
// <</slug>> — command pill
if (matchStart > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, matchStart) });
}
segments.push({ type: 'command-pill', slug: match[2].toLowerCase() });
lastIndex = matchStart + match[0].length;
} else if (match[4] !== undefined) {
// plain @slug — subtle highlight
const prefix = match[3]; // whitespace or empty
const token = match[4];
const beforeText = text.slice(lastIndex, matchStart) + prefix;
if (beforeText) {
segments.push({ type: 'text', value: beforeText });
}
segments.push({ type: 'mention', value: token });
lastIndex = matchStart + match[0].length;
} else if (match[6] !== undefined) {
// plain /slug — subtle highlight
const prefix = match[5]; // whitespace or empty
const token = match[6];
const beforeText = text.slice(lastIndex, matchStart) + prefix;
if (beforeText) {
segments.push({ type: 'text', value: beforeText });
}
segments.push({ type: 'command', value: token });
lastIndex = matchStart + match[0].length;
}
}
// Add remaining text
if (lastIndex < text.length) {
segments.push({ type: 'text', value: text.slice(lastIndex) });
}
// If no matches at all, return the full text
if (segments.length === 0) {
segments.push({ type: 'text', value: text });
}
// Second pass: split text segments to extract URLs
return splitTextSegmentsForUrls(segments);
}
/**
* URL regex matching:
* - https://... or http://...
* - www. followed by domain (auto-prefixed with https:// for href)
* Stops at whitespace, quotes, or common trailing punctuation not part of URLs.
*/
const URL_REGEX = /(?:https?:\/\/|www\.)[^\s<>"'`)\]]+[^\s<>"'`)\].,;:!?]/gi;
/**
* Takes a segment array and splits any 'text' segments into 'text' and 'url'
* segments wherever URLs are found.
*/
function splitTextSegmentsForUrls(segments: Segment[]): Segment[] {
const result: Segment[] = [];
for (const seg of segments) {
if (seg.type !== 'text') {
result.push(seg);
continue;
}
const text = seg.value;
let lastIndex = 0;
let match: RegExpExecArray | null;
// Reset regex state for each segment
URL_REGEX.lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
// Push text before the URL
if (match.index > lastIndex) {
result.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
result.push({ type: 'url', value: match[0] });
lastIndex = match.index + match[0].length;
}
// Push remaining text after last URL (or entire text if no URLs found)
if (lastIndex < text.length) {
result.push({ type: 'text', value: text.slice(lastIndex) });
}
}
return result;
}
export const HighlightedText: React.FC<HighlightedTextProps> = ({
text,
onMentionClick,
className,
}) => {
const handleClick = useCallback(
(token: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onMentionClick?.(token);
},
[onMentionClick]
);
if (!text) return null;
const segments = parseSegments(text);
// If there are no mentions or URLs, just render plain text (fast path)
if (segments.length === 1 && segments[0].type === 'text') {
return <span className={className}>{text}</span>;
}
return (
<span className={cn('whitespace-pre-wrap', className)}>
{segments.map((seg, idx) => {
switch (seg.type) {
case 'text':
return <React.Fragment key={idx}>{seg.value}</React.Fragment>;
case 'url':
return (
<a
key={idx}
href={seg.value.startsWith('http') ? seg.value : `https://${seg.value}`}
target="_blank"
rel="noopener noreferrer"
className="underline break-all text-[var(--color-primary-400)] hover:text-[var(--color-primary-300)] transition-colors"
onClick={(e) => e.stopPropagation()}
>
{seg.value}
</a>
);
case 'mention-pill':
// ADR-116: <<@slug>> — invocation pill
return (
<MentionPill
key={idx}
slug={seg.slug}
onClick={onMentionClick}
/>
);
case 'command-pill':
// ADR-116: <</slug>> — invocation pill
return (
<CommandPill
key={idx}
slug={seg.slug}
onClick={onMentionClick}
/>
);
case 'mention':
// Plain @slug — subtle reference highlight (blue text)
return (
<span
key={idx}
onClick={(e) => handleClick(seg.value, e)}
className={cn(
'cursor-pointer font-medium transition-colors',
'text-[var(--color-primary-500)] hover:text-[var(--color-primary-400)]',
'hover:underline'
)}
title={`Mention ${seg.value}`}
role="button"
tabIndex={0}
>
{seg.value}
</span>
);
case 'command':
// Plain /slug — subtle reference highlight (purple text)
return (
<span
key={idx}
onClick={(e) => handleClick(seg.value, e)}
className={cn(
'cursor-pointer font-medium transition-colors',
'text-purple-400 hover:text-purple-300',
'hover:underline'
)}
title={`Command ${seg.value}`}
role="button"
tabIndex={0}
>
{seg.value}
</span>
);
default:
return null;
}
})}
</span>
);
};
export default HighlightedText;