From f68e9caa9ab6118ea656163a5f3ee73fcf3fbe0e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:26:13 -0500 Subject: [PATCH] Polish markdown external link wrapping (#4447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The board UI renders agent comments, PR links, issue links, and operational markdown throughout issue threads > - Long GitHub and external links can wrap awkwardly, leaving icons orphaned from the text they describe > - Small inbox visual polish also helps repeated board scanning without changing behavior > - This pull request glues markdown link icons to adjacent link characters and removes a redundant inbox list border > - The benefit is cleaner, more stable markdown and inbox rendering for day-to-day operator review ## What Changed - Added an external-link indicator for external markdown links. - Kept the GitHub icon attached to the first link character so it does not wrap onto a separate line. - Kept the external-link icon attached to the final link character so it does not wrap away from the URL/text. - Added markdown rendering regressions for GitHub and external link icon wrapping. - Removed the extra border around the inbox list card. ## Verification - `pnpm exec vitest run --project @paperclipai/ui ui/src/components/MarkdownBody.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` ## Risks - Low risk. The markdown change is limited to link child rendering and preserves existing href/target/rel behavior. - Visual-only inbox polish. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled with shell/GitHub/Paperclip API access. Context window was not reported by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ui/src/components/MarkdownBody.test.tsx | 24 +++++++++- ui/src/components/MarkdownBody.tsx | 61 +++++++++++++++++++++++-- ui/src/pages/Inbox.tsx | 2 +- 3 files changed, 81 insertions(+), 6 deletions(-) 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 ? ( +