diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 4ff06da2..b30c3edf 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -26,22 +26,52 @@ import { thematicBreakPlugin, type RealmPlugin, } from "@mdxeditor/editor"; -import { LinkNode } from "@lexical/link"; +import { LinkNode, type LinkAttributes } from "@lexical/link"; import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared"; import { AgentIcon } from "./AgentIconPicker"; import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips"; import { mentionDeletionPlugin } from "../lib/mention-deletion"; import { cn } from "../lib/utils"; -/* ---- Allow custom mention URL schemes in Lexical's LinkNode ---- */ -// Lexical only allows http(s)/mailto/sms/tel by default, converting -// everything else to about:blank. We need agent:// and project:// -// to survive the markdown→Lexical import so mention chips render. -const _origSanitizeUrl = LinkNode.prototype.sanitizeUrl; -LinkNode.prototype.sanitizeUrl = function sanitizeUrl(url: string): string { - if (/^(agent|project):\/\//.test(url)) return url; - return _origSanitizeUrl.call(this, url); -}; +const CUSTOM_MENTION_URL_RE = /^(agent|project):\/\//; + +class MentionAwareLinkNode extends LinkNode { + static clone(node: MentionAwareLinkNode): MentionAwareLinkNode { + return new MentionAwareLinkNode( + node.getURL(), + { + rel: node.getRel(), + target: node.getTarget(), + title: node.getTitle(), + }, + node.getKey(), + ); + } + + constructor(url?: string, attributes?: LinkAttributes, key?: string) { + super(url, attributes, key); + } + + sanitizeUrl(url: string): string { + if (CUSTOM_MENTION_URL_RE.test(url)) return url; + return super.sanitizeUrl(url); + } +} + +const mentionAwareLinkNodeReplacement = { + replace: LinkNode, + with: (node: LinkNode) => + new MentionAwareLinkNode( + node.getURL(), + { + rel: node.getRel(), + target: node.getTarget(), + title: node.getTitle(), + }, + node.getKey(), + ), + withKlass: MentionAwareLinkNode, +} as const; /* ---- Mention types ---- */ @@ -560,6 +590,7 @@ export const MarkdownEditor = forwardRef "paperclip-mdxeditor-content focus:outline-none [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:list-item", contentClassName, )} + additionalLexicalNodes={[mentionAwareLinkNodeReplacement]} plugins={plugins} /> diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 6acb9af8..86746dcc 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -664,6 +664,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const { pushToast } = useToast(); const location = useLocation(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); + const pathnameRef = useRef(location.pathname); const { data: session } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -671,6 +672,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + useEffect(() => { + pathnameRef.current = location.pathname; + }, [location.pathname]); + useEffect(() => { if (!selectedCompanyId) return; @@ -715,7 +720,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, location.pathname, parsed, pushToast, gateRef.current, { + handleLiveEvent(queryClient, selectedCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, { userId: currentUserId, agentId: null, }); @@ -747,7 +752,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { socket.close(1000, "provider_unmount"); } }; - }, [queryClient, selectedCompanyId, pushToast, currentUserId, location.pathname]); + }, [queryClient, selectedCompanyId, pushToast, currentUserId]); return <>{children}; } diff --git a/ui/src/lib/mention-chips.ts b/ui/src/lib/mention-chips.ts index d00185a8..d951d0ce 100644 --- a/ui/src/lib/mention-chips.ts +++ b/ui/src/lib/mention-chips.ts @@ -112,15 +112,11 @@ function buildAgentIconMask(iconName: string | null): string | null { if (cached) return cached; const Icon = getAgentIcon(iconName); - const rendered = ( + const iconNode = ( Icon as unknown as { - render: ( - props: Record, - ref: unknown, - ) => { props?: { iconNode?: Array<[string, Record]> } }; + iconNode?: Array<[string, Record]>; } - ).render({ size: 12, strokeWidth: 2 }, null); - const iconNode = rendered?.props?.iconNode; + ).iconNode; if (!Array.isArray(iconNode) || iconNode.length === 0) return null; const body = iconNode.map(([tag, attrs]) => {