From 25c9e92ca422965645ad50567d5c06ff1db2adb7 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 14:52:10 -0500 Subject: [PATCH] Glue icon to link text, add external-link indicator PAP-2240: the GitHub icon on github.com markdown links could wrap onto the previous line, separated from the first character of the URL. Pin the icon and first character inside a white-space:nowrap span so they never split across a line break. Also add an ExternalLink (lucide) icon on the right of any target="_blank" link, glued to the last character with the same no-wrap treatment so the "opens in new tab" cue stays with the link text. Co-Authored-By: Paperclip --- ui/src/components/MarkdownBody.test.tsx | 23 +++++++++- ui/src/components/MarkdownBody.tsx | 59 +++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 63e92cb5810..326527a3eda 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -316,12 +316,15 @@ 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"); }); it("prefixes GitHub autolinks with the GitHub icon", () => { @@ -338,6 +341,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 6ab4ff3768e..0be4eb81e2d 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,54 @@ function isExternalHttpUrl(href: string | null | undefined): boolean { } } +function renderLinkBody( + children: ReactNode, + leadingIcon: ReactNode, + trailingIcon: ReactNode, +): ReactNode { + if (!leadingIcon && !trailingIcon) return children; + + 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 +329,12 @@ export function MarkdownBody({ } const isGitHubLink = isGitHubUrl(href); const isExternal = isExternalHttpUrl(href); + const leadingIcon = isGitHubLink ? ( +