Polish markdown external link wrapping (#4447)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-24 19:26:13 -05:00
committed by GitHub
parent 73fbdf36db
commit f68e9caa9a
3 changed files with 81 additions and 6 deletions

View File

@@ -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('<a href="https://github.com/paperclipai/paperclip/pull/4099"');
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-[-0.125em]"');
expect(html).toContain(">https://github.com/paperclipai/paperclip/pull/4099</a>");
// 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(/<span style="white-space:nowrap">.*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(/<span style="white-space:nowrap">s<svg[^>]*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```");

View File

@@ -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 (
<span style={{ whiteSpace: "nowrap" }}>
{leadingIcon}
{children}
{trailingIcon}
</span>
);
}
const first = children[0];
const last = children[children.length - 1];
const middle = children.slice(1, -1);
return (
<>
{leadingIcon ? (
<span style={{ whiteSpace: "nowrap" }}>
{leadingIcon}
{first}
</span>
) : first}
{middle}
{trailingIcon ? (
<span style={{ whiteSpace: "nowrap" }}>
{last}
{trailingIcon}
</span>
) : 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<string | null>(null);
@@ -281,6 +331,12 @@ export function MarkdownBody({
}
const isGitHubLink = isGitHubUrl(href);
const isExternal = isExternalHttpUrl(href);
const leadingIcon = isGitHubLink ? (
<Github aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-[-0.125em]" />
) : null;
const trailingIcon = isExternal && !isGitHubLink ? (
<ExternalLink aria-hidden="true" className="ml-1 inline h-3 w-3 align-[-0.125em]" />
) : null;
return (
<a
href={href}
@@ -289,8 +345,7 @@ export function MarkdownBody({
: { rel: "noreferrer" })}
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
>
{isGitHubLink ? <Github aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-[-0.125em]" /> : null}
{linkChildren}
{renderLinkBody(linkChildren, leadingIcon, trailingIcon)}
</a>
);
},

View File

@@ -2122,7 +2122,7 @@ export function Inbox() {
<>
{showSeparatorBefore("work_items") && <Separator />}
<div>
<div ref={listRef} className="overflow-hidden rounded-xl border border-border bg-card">
<div ref={listRef} className="overflow-hidden rounded-xl bg-card">
{(() => {
const renderInboxIssue = ({
issue,