diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 63e92cb5..8f64bf8f 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -316,12 +316,16 @@ describe("MarkdownBody", () => { expect(html).toContain('rel="noreferrer"'); }); - it("prefixes GitHub markdown links with the GitHub icon", () => { + it("prefixes GitHub markdown links with the GitHub icon glued to the first character", () => { const html = renderMarkdown("[https://github.com/paperclipai/paperclip/pull/4099](https://github.com/paperclipai/paperclip/pull/4099)"); expect(html).toContain('https://github.com/paperclipai/paperclip/pull/4099"); + // The icon and first character "h" must sit in a no-wrap span so the + // icon can never be orphaned on the previous line from the URL text. + expect(html).toMatch(/.*lucide-github.*?<\/svg>h<\/span>/); + expect(html).toContain("ttps://github.com/paperclipai/paperclip/pull/4099"); + expect(html).not.toContain("lucide-external-link"); }); it("prefixes GitHub autolinks with the GitHub icon", () => { @@ -338,6 +342,22 @@ describe("MarkdownBody", () => { expect(html).not.toContain("lucide-github"); }); + it("suffixes external links with a new-tab icon glued to the last character", () => { + const html = renderMarkdown("[docs](https://example.com/docs)"); + + expect(html).toContain('target="_blank"'); + expect(html).toContain("lucide-external-link"); + // Last character "s" must sit in a no-wrap span with the icon so the + // indicator never wraps away from the link text. + expect(html).toMatch(/s]*lucide-external-link/); + }); + + it("does not render the new-tab icon on internal links", () => { + const html = renderMarkdown("[settings](/company/settings)"); + + expect(html).not.toContain("lucide-external-link"); + }); + it("keeps fenced code blocks width-bounded and horizontally scrollable", () => { const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 6ab4ff37..b155032f 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,6 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Github } from "lucide-react"; +import { ExternalLink, Github } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; @@ -133,6 +133,56 @@ function isExternalHttpUrl(href: string | null | undefined): boolean { } } +function renderLinkBody( + children: ReactNode, + leadingIcon: ReactNode, + trailingIcon: ReactNode, +): ReactNode { + if (!leadingIcon && !trailingIcon) return children; + + // React-markdown can pass arrays/elements for styled link text; the nowrap + // splitting below is intentionally limited to plain text links. + if (typeof children === "string" && children.length > 0) { + if (children.length === 1) { + return ( + + {leadingIcon} + {children} + {trailingIcon} + + ); + } + const first = children[0]; + const last = children[children.length - 1]; + const middle = children.slice(1, -1); + return ( + <> + {leadingIcon ? ( + + {leadingIcon} + {first} + + ) : first} + {middle} + {trailingIcon ? ( + + {last} + {trailingIcon} + + ) : last} + + ); + } + + return ( + <> + {leadingIcon} + {children} + {trailingIcon} + + ); +} + function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) { const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const [svg, setSvg] = useState(null); @@ -281,6 +331,12 @@ export function MarkdownBody({ } const isGitHubLink = isGitHubUrl(href); const isExternal = isExternalHttpUrl(href); + const leadingIcon = isGitHubLink ? ( +