Files
PaperClipAI/ui/src/components/MarkdownBody.test.tsx
T
DottaandPaperclip 6148fbd31f feat(ui): distinguish GitHub resources in inline Markdown links (#19476)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - Task conversations and documents use the shared Markdown renderer.
> - GitHub repository, pull request, and issue links currently use the
same icon.
> - Long pasted URLs make these references hard to scan in a
conversation.
> - This change identifies each resource from its URL and shows a
compact label with a distinct icon.
> - The exact destination and custom link text stay intact. Connected
status data keeps its existing behavior.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Inline GitHub links in rendered Markdown.

**Current behavior**

Repositories, pull requests, and issues all use the GitHub mark. Pasted
resource links show the full URL.

**Proposed behavior**

Pull requests use the pull request icon. Issues use the issue icon.
Repositories and other GitHub pages keep the brand mark. Pasted resource
URLs show `owner/repository#number` or `owner/repository`. Custom labels
keep their text and formatting.

**Reason and benefit**

Readers can identify GitHub resource types and scan references in a
narrow conversation. Full URLs, query strings, and comment anchors stay
available through the link destination. Link rendering makes no remote
metadata requests.

**Breaking changes**

The visible text of a bare GitHub resource URL becomes compact. The
destination does not change. No API, database, or connection changes are
required.

Searches found no duplicate work for GitHub inline resource
presentation. Related work: [same-origin Markdown
routing](https://github.com/paperclipai/paperclip/pull/17864). This
change preserves that route handling.

## What Changed

- Add a pure GitHub URL classifier with exact host checks and resource
labels.
- Move ordinary URL presentation into `MarkdownUrlLink`. Keep internal
references, workspace links, custom resolvers, and connected metadata in
their existing order.
- Add separate icons and accessible resource names. Preserve authored
Markdown titles and formatted labels.
- Add Playground, Narrow, and Streaming Storybook examples that mount
`MarkdownBody`.
- Add parser and renderer tests, eight Chromium browser cases, and the
browser command in the development guide.
- Consider Open Graph loading. Use the existing connected metadata path
for remote status and titles. Add no arbitrary page fetches.

## Verification

- Focused Vitest coverage: 129 passing tests across nine files for
GitHub parsing, Markdown rendering, connected metadata, document
rendering, and workspace-file rendering.
- `pnpm check:token-gates`.
- `pnpm --filter @paperclipai/ui typecheck`.
- `pnpm --filter @paperclipai/ui build`.
- `pnpm build-storybook`.
- Storybook catalog generation/check and 30 catalog/map tests.
- Site-route regression: Stars, Education, and enterprise pages keep
general GitHub branding and their full URL text. Three parser cases
failed before the fix; parser and renderer cases now pass.
- Streaming regression: three resource cases failed before the fix and
pass after it. The fix preserves the generated word span and custom
formatted labels.
- Chromium: `STORYBOOK_TEST_BASE_URL=http://localhost:6106 pnpm exec
playwright test inline-links.spec.ts
--config=tests/storybook-visual/playwright.config.ts --project=stories`.
- Browser scope: eight passing cases for ordinary/streaming modes,
dark/light at desktop and 390px mobile, completed play assertions, exact
comment destinations, safe external attributes, keyboard focus,
wrapping, and no GitHub requests. Screenshots are saved as task
artifacts; no review images are committed.
- The first browser attempt in the resumed environment could not launch
Chromium because its executable was missing. The browser was installed,
and all eight cases then passed.
- The browser cases use synthetic text and one connected metadata
fixture. They do not prove provider authentication or server
persistence. No visual baseline comparison is claimed; the repository
baseline manifest is unpublished.
- Initial typecheck found two unsupported story assertion options and an
unbuilt SDK dependency in the fresh checkout. The options were removed
and the SDK was built before the successful retry.
- `pnpm verify` returned 2 because the new browser spec requires broad
CI coverage. Focused tests and the browser suite supply local evidence.
Required CI supplies broad coverage.

## Risks

- Compact labels hide URL detail from the visible line. The exact href
and default tooltip preserve that detail.
- GitHub site routes can change. Unknown paths retain the general GitHub
treatment. Lookalike hosts and credential-bearing URLs do not get GitHub
resource treatment.
- Connected external-object status and title rendering keeps priority
over URL-derived presentation.

## Model Used

OpenAI Codex, GPT-6 (`gpt-6` family), with reasoning, tool use, browser
checks, and code execution. The exact deployment ID and context window
are not exposed in this run.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-06 15:26:10 -06:00

810 lines
33 KiB
TypeScript

// @vitest-environment node
import type { ComponentProps, ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import {
buildAgentMentionHref,
buildIssueReferenceHref,
buildProjectMentionHref,
buildRoutineMentionHref,
buildSkillMentionHref,
buildUserMentionHref,
appearanceForPalette,
} from "@paperclipai/shared";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
import { MarkdownWordStreaming } from "./MarkdownWordStreaming";
import { queryKeys } from "../lib/queryKeys";
import type { WorkspaceFileAvailabilityTarget } from "../lib/workspace-file-availability";
/** Stands in for a server-confirmed openable reference in the issue's workspace. */
const OPENABLE_AUTO_TARGET: WorkspaceFileAvailabilityTarget = {
workspace: "auto",
projectId: null,
workspaceId: null,
projectName: null,
};
const mockIssuesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
useLocation: () => ({
pathname: "/PAP/issues/PAP-10306",
search: "",
hash: "",
state: null,
}),
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
// Defaults to null (no provider) so the existing suite exercises the permissive
// path unchanged. Gating tests override the return value per-case.
const mockUseOptionalCompany = vi.hoisted(() => vi.fn<() => { companies: Array<{ issuePrefix: string }>; selectedCompanyId?: string } | null>(() => null));
vi.mock("../context/CompanyContext", () => ({
useOptionalCompany: mockUseOptionalCompany,
}));
afterEach(() => {
mockUseOptionalCompany.mockReturnValue(null);
});
function renderMarkdown(
children: string,
seededIssues: Array<{ identifier: string; status: string; title?: string; cacheKey?: string }> = [],
props: Partial<ComponentProps<typeof MarkdownBody>> = {},
streaming = false,
) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
for (const issue of seededIssues) {
queryClient.setQueryData(queryKeys.issues.detail(issue.cacheKey ?? issue.identifier), {
id: issue.identifier,
identifier: issue.identifier,
status: issue.status,
title: issue.title,
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownWordStreaming.Provider value={streaming}>
<MarkdownBody {...props}>{children}</MarkdownBody>
</MarkdownWordStreaming.Provider>
</ThemeProvider>
</QueryClientProvider>,
);
}
describe("MarkdownBody", () => {
it("preserves a saved document anchor in an explicit task link", () => {
// Hover/focus can resolve the issue to its plain identifier. Both the old
// fragment-bearing cache key and the corrected plain key model that load.
const html = renderMarkdown("[Saved document](/PAP/issues/PAP-1271#document-output)", [
{ identifier: "PAP-1271", status: "done" },
{ identifier: "PAP-1271", status: "done", cacheKey: "PAP-1271#document-output" },
], { linkIssueReferences: true });
expect(html).toContain('href="/issues/PAP-1271#document-output"');
expect(html).not.toContain("PAP-1271%23document-output");
});
it("renders markdown images without a resolver", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody>{"![](/api/attachments/test/content)"}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).toContain('<img src="/api/attachments/test/content" alt=""/>');
});
it("resolves relative image paths when a resolver is provided", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody resolveImageSrc={(src) => `/resolved/${src}`}>
{"![Org chart](images/org-chart.png)"}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).toContain('src="/resolved/images/org-chart.png"');
expect(html).toContain('alt="Org chart"');
});
it("renders decision images as inert references without resolving or preloading them", () => {
const resolveImageSrc = vi.fn((src: string) => `https://resolver.invalid/${src}`);
const html = renderMarkdown(
'# Plan\n\n**Keep this step.**\n\n![Evidence](https://provider.invalid/track.png "Provider image")\n\n![Relative](images/proof.png)\n\n![Protocol relative](//provider.invalid/other.png)',
[], { mediaMode: "reference", resolveImageSrc },
);
expect(html).toContain("<h1>Plan</h1>");
expect(html).toContain("<strong>Keep this step.</strong>");
expect(html).toContain("Image: Evidence (https://provider.invalid/track.png)");
expect(html).toContain('title="Provider image"');
expect(html).toContain("Image: Relative (images/proof.png)");
expect(html).toContain("Image: Protocol relative (//provider.invalid/other.png)");
expect(html).not.toMatch(/<(?:img|link|iframe|video|audio)\b/);
expect(resolveImageSrc).not.toHaveBeenCalled();
});
it("keeps diagram source inert in decision media mode", () => {
const source = 'flowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }';
const html = renderMarkdown(`\`\`\`mermaid\n${source}\n\`\`\``, [], { mediaMode: "reference" });
expect(html).toContain('class="language-mermaid"');
expect(html).toContain("https://provider.invalid/diagram.png");
expect(html).not.toContain('class="paperclip-mermaid"');
expect(html).not.toContain("Rendering Mermaid diagram");
});
it("renders user, agent, project, skill, and routine mentions as chips", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody>
{`[@Taylor](${buildUserMentionHref("user-123")}) [@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")}) [/routine:Weekly review](${buildRoutineMentionHref("routine-123")})`}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).toContain('href="/company/settings/access"');
expect(html).toContain('data-mention-kind="user"');
expect(html).toContain('href="/agents/agent-123"');
expect(html).toContain('data-mention-kind="agent"');
expect(html).toContain("--paperclip-mention-avatar-image");
expect(html).not.toContain("--paperclip-mention-icon-mask");
expect(html).toContain('href="/projects/project-456"');
expect(html).toContain('data-mention-kind="project"');
expect(html).toContain("--paperclip-mention-project-color:#336699");
expect(html).toContain('href="/skills/skill-789"');
expect(html).toContain('data-mention-kind="skill"');
expect(html).toContain('href="/routines/routine-123"');
expect(html).toContain('data-mention-kind="routine"');
});
it("sanitizes unsafe javascript markdown links", () => {
const html = renderMarkdown("[click me](javascript:alert(document.cookie))");
expect(html).toContain('<a href="" rel="noreferrer"');
expect(html).toContain(">click me</a>");
expect(html).not.toContain("javascript:");
});
it("uses the current company agent appearance for an old saved mention", () => {
mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "PAP" }], selectedCompanyId: "company-1" });
const client = new QueryClient();
client.setQueryData(queryKeys.agents.list("company-1"), [
{ id: "agent-123", appearance: appearanceForPalette("electric-grove") },
]);
const html = renderToStaticMarkup(
<QueryClientProvider client={client}>
<ThemeProvider>
<MarkdownBody>{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")})`}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).toContain("/cap-v1/electric-grove/rest.png");
expect(html).not.toContain("mention-icon-mask");
});
it("renders raw HTML tags as escaped text", () => {
const html = renderMarkdown(
'<script>fetch("/api/secrets")</script>\n<iframe src="https://example.com"></iframe>\n<p onclick="steal()">Plain text</p>',
);
expect(html).not.toContain("<script>");
expect(html).not.toContain("<iframe");
expect(html).not.toContain("<p onclick");
expect(html).not.toContain('onclick="steal()"');
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("onclick=&quot;steal()&quot;");
expect(html).toContain("Plain text");
});
it("hides markdown HTML comments instead of rendering placeholder text", () => {
const html = renderMarkdown("Before\n\n<!-- -->\n\nAfter");
expect(html).toContain("Before");
expect(html).toContain("After");
expect(html).not.toContain("&lt;!--");
expect(html).not.toContain("--&gt;");
});
it("hides escaped HTML comment placeholders before attachment images", () => {
const html = renderMarkdown("\\<!-- --> ![](/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content)");
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
expect(html).not.toContain("&lt;!--");
expect(html).not.toContain("--&gt;");
});
it("hides incomplete streamed HTML comment placeholders before attachment images", () => {
const html = renderMarkdown("\\<!-- ![](/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content)");
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
expect(html).not.toContain("&lt;!--");
});
it("hides incomplete encoded HTML comment placeholders", () => {
const html = renderMarkdown("&lt;!-- -- ![](/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content)");
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
expect(html).not.toContain("&lt;!--");
expect(html).not.toContain("&amp;lt;!--");
});
it("keeps HTML comment markers when they are literal code content", () => {
const inlineHtml = renderMarkdown("Use `<!-- -->` as a literal.");
const blockHtml = renderMarkdown("```html\n<!-- keep this example -->\n```");
expect(inlineHtml).toContain("&lt;!-- --&gt;");
expect(blockHtml).toContain("&lt;!-- keep this example --&gt;");
});
it("uses soft-break styling by default", () => {
const html = renderMarkdown("First line\nSecond line");
expect(html).toContain("First line<br/>");
expect(html).toContain("Second line");
});
it("can opt out of soft-break styling", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody softBreaks={false}>
{"First line\nSecond line"}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).not.toContain("<br/>");
});
it("does not inject extra line-break nodes into nested lists", () => {
const html = renderMarkdown("1. Parent item\n - child a\n - child b\n\n2. Second item");
expect(html).not.toContain("[&amp;_p]:whitespace-pre-line");
expect(html).not.toContain("Parent item<br/>");
expect(html).toContain("<ol>");
expect(html).toContain("<ul>");
});
it("linkifies bare issue identifiers in markdown text", () => {
const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [
{ identifier: "PAP-1271", status: "done" },
]);
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).toContain("var(--status-task-icon-done)");
expect(html).toContain(">PAP-1271<");
expect(html).toContain('data-mention-kind="issue"');
expect(html).toContain("paperclip-markdown-issue-ref");
expect(html).not.toContain("paperclip-mention-chip--issue");
});
it("holds the quicklook preview until an issue mention is confirmed readable (PAP-16070)", () => {
// While the fetch is in flight we don't know if the viewer can read the
// issue. The mention stays a clickable link, but the IssueLinkQuicklook
// hover preview (a Radix Popover portal) must NOT mount yet: a private
// mention 404s straight to the locked chip, and mounting then tearing down
// that portal to swap in the chip is the element churn that crashed the
// chat transcript renderer.
const html = renderMarkdown("Depends on PAP-1271 for the hover state.");
expect(html).toContain('data-mention-pending="true"');
expect(html).toContain('data-mention-kind="issue"');
expect(html).toContain("paperclip-markdown-issue-ref");
// Clickability is preserved through the loading state.
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).toContain(">PAP-1271<");
});
it("drops the pending marker once a seeded (readable) issue mention resolves", () => {
const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [
{ identifier: "PAP-1271", status: "done" },
]);
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).not.toContain('data-mention-pending="true"');
});
it("uses concise issue aria labels until a distinct title is available", () => {
const html = renderMarkdown("Depends on PAP-1271 and PAP-1272.", [
{ identifier: "PAP-1271", status: "done" },
{ identifier: "PAP-1272", status: "blocked", title: "Fix hover state" },
]);
expect(html).toContain('aria-label="Issue PAP-1271"');
expect(html).toContain('aria-label="Issue PAP-1272: Fix hover state"');
expect(html).not.toContain('aria-label="Issue PAP-1271: PAP-1271"');
});
it("preserves absolute issue URLs as external links", () => {
const url = "http://remote.example.test:3103/PAPA/issues/PAPA-115#comment-850083f3-24de-43e7-a8cd-bc01f7cc9f0d";
const html = renderMarkdown(`See ${url}.`, [
{ identifier: "PAPA-115", status: "blocked" },
]);
expect(html).toContain(`href="${url}"`);
expect(html).toContain('target="_blank"');
expect(html).toContain("lucide-external-link");
expect(html).not.toContain('href="/issues/PAPA-115"');
expect(html).not.toContain("paperclip-markdown-issue-ref");
});
it("linkifies plain internal issue paths in markdown text", () => {
const html = renderMarkdown("See /issues/PAP-1179 and /PAP/issues/pap-1180 for context.", [
{ identifier: "PAP-1179", status: "blocked" },
{ identifier: "PAP-1180", status: "done" },
]);
expect(html).toContain('href="/issues/PAP-1179"');
expect(html).toContain('href="/issues/PAP-1180"');
expect(html).toContain(">/issues/PAP-1179<");
expect(html).toContain(">/PAP/issues/pap-1180<");
expect(html).toContain("var(--status-task-icon-blocked)");
expect(html).toContain("var(--status-task-icon-done)");
});
it("does not auto-link non-issue internal route paths", () => {
const html = renderMarkdown("Use /issues/new for the creation form, /issues/PAP-42extra as text, and /api/issues for data.");
expect(html).toContain("Use /issues/new for the creation form, /issues/PAP-42extra as text, and /api/issues for data.");
expect(html).not.toContain('href="/issues/new"');
expect(html).not.toContain('href="/issues/PAP-42"');
expect(html).not.toContain('data-mention-kind="issue"');
});
it("rewrites issue scheme links to internal issue links", () => {
const html = renderMarkdown("See issue://PAP-1310 and issue://:PAP-1311.", [
{ identifier: "PAP-1310", status: "done" },
{ identifier: "PAP-1311", status: "blocked" },
]);
expect(html).toContain('href="/issues/PAP-1310"');
expect(html).toContain('href="/issues/PAP-1311"');
expect(html).toContain(">issue://PAP-1310<");
expect(html).toContain(">issue://:PAP-1311<");
expect(html).toContain("var(--status-task-icon-done)");
expect(html).toContain("var(--status-task-icon-blocked)");
});
it("linkifies issue identifiers inside inline code spans", () => {
const html = renderMarkdown("Reference `PAP-1271` here.", [
{ identifier: "PAP-1271", status: "done" },
]);
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).toContain('<code style="overflow-wrap:anywhere;word-break:break-word">PAP-1271</code>');
expect(html).toContain("var(--status-task-icon-done)");
expect(html).toContain("paperclip-markdown-issue-ref");
});
it("renders linked inline-code workspace paths as file viewer links before issue links", () => {
const html = renderMarkdown(
"- **MP4**: [`videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4`](/PAP/issues/PAP-10306 \"Publish handoff\")",
[{ identifier: "PAP-10306", status: "in_review", title: "Publish handoff" }],
{ resolveWorkspaceFileRef: () => OPENABLE_AUTO_TARGET },
);
expect(html).toContain('data-workspace-file-link="true"');
expect(html).toContain('data-workspace-file-path="videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4"');
expect(html).toContain("videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4");
expect(html).not.toContain("max-w-(--sz-38ch)");
expect(html).not.toContain("paperclip-markdown-issue-ref");
expect(html).not.toContain('href="/issues/PAP-10306"');
});
it("renders auto-detected workspace paths as plain code without an availability resolver", () => {
const html = renderMarkdown("Check `ui/src/pages/IssueDetail.tsx:42` please.");
expect(html).not.toContain("data-workspace-file-link");
expect(html).not.toContain("paperclip-workspace-file-link");
expect(html).toContain("ui/src/pages/IssueDetail.tsx:42");
});
it("keeps a non-openable auto-detected path as plain code with no chip affordances", () => {
const html = renderMarkdown(
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
[],
{ resolveWorkspaceFileRef: () => null },
);
expect(html).not.toContain("data-workspace-file-link");
expect(html).not.toContain('role="button"');
expect(html).not.toContain("paperclip-workspace-file-link");
expect(html).toContain("<code");
});
it("keeps an explicit markdown link ordinary when its path is not openable", () => {
const html = renderMarkdown(
"See [`ui/src/a.ts:1`](/PAP/issues/PAP-10306)",
[{ identifier: "PAP-10306", status: "todo" }],
{ resolveWorkspaceFileRef: () => null },
);
expect(html).not.toContain("data-workspace-file-link");
expect(html).toContain('href="/issues/PAP-10306"');
});
it("promotes an openable auto-detected path to a workspace file chip", () => {
const html = renderMarkdown(
"Check `ui/src/pages/IssueDetail.tsx:42` please.",
[],
{ resolveWorkspaceFileRef: () => OPENABLE_AUTO_TARGET },
);
expect(html).toContain('data-workspace-file-link="true"');
expect(html).toContain('data-workspace-file-path="ui/src/pages/IssueDetail.tsx"');
});
it("keeps trailing punctuation outside auto-linked issue references", () => {
const html = renderMarkdown("See PAP-1271: /issues/PAP-1272] and issue://PAP-1273.", [
{ identifier: "PAP-1271", status: "done" },
{ identifier: "PAP-1272", status: "blocked" },
{ identifier: "PAP-1273", status: "todo" },
]);
expect(html).toContain('<a href="/issues/PAP-1271"');
expect(html).toContain('>PAP-1271</a>:');
expect(html).toContain('<a href="/issues/PAP-1272"');
expect(html).toContain('>/issues/PAP-1272</a>]');
expect(html).toContain('<a href="/issues/PAP-1273"');
expect(html).toContain('>issue://PAP-1273</a>.');
});
it("can opt out of issue reference linkification for offline previews", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<ThemeProvider>
<MarkdownBody linkIssueReferences={false}>
{"Depends on PAP-1271 and [manual link](PAP-1271)."}
</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
expect(html).not.toContain('href="/issues/PAP-1271"');
expect(html).toContain("Depends on PAP-1271");
expect(html).toContain('href="PAP-1271"');
});
it("leaves wiki links as text unless explicitly enabled", () => {
const html = renderMarkdown("See [[wiki/entities/paperclip]].");
expect(html).toContain("[[wiki/entities/paperclip]]");
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip.md"');
});
it("renders wiki links with a custom resolver when enabled", () => {
const html = renderMarkdown(
"See [[wiki/entities/paperclip|Paperclip]] and [[wiki/entities/dotta-b]].",
[],
{
enableWikiLinks: true,
resolveWikiLinkHref: (target) => `/wiki/page/${target.endsWith(".md") ? target : `${target}.md`}`,
},
);
expect(html).toContain('href="/wiki/page/wiki/entities/paperclip.md"');
expect(html).toContain('data-paperclip-wiki-link="true"');
expect(html).toContain('data-paperclip-wiki-target="wiki/entities/paperclip"');
expect(html).toContain(">Paperclip</a>");
expect(html).toContain('href="/wiki/page/wiki/entities/dotta-b.md"');
expect(html).toContain(">wiki/entities/dotta-b</a>");
expect(html).not.toContain("[[wiki/entities/paperclip");
});
it("keeps wiki links as text when the custom resolver rejects them", () => {
const html = renderMarkdown(
"See [[wiki/entities/paperclip]].",
[],
{
enableWikiLinks: true,
wikiLinkRoot: "/wiki/page",
resolveWikiLinkHref: () => null,
},
);
expect(html).toContain("[[wiki/entities/paperclip]]");
expect(html).not.toContain('data-paperclip-wiki-link="true"');
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
});
it("does not render wiki links inside code spans or code blocks", () => {
const html = renderMarkdown(
"Inline `[[wiki/entities/paperclip]]`.\n\n```md\n[[wiki/entities/dotta-b]]\n```",
[],
{
enableWikiLinks: true,
wikiLinkRoot: "/wiki/page",
},
);
expect(html).toContain("[[wiki/entities/paperclip]]");
expect(html).toContain("[[wiki/entities/dotta-b]]");
expect(html).not.toContain('href="/wiki/page/wiki/entities/paperclip"');
expect(html).not.toContain('href="/wiki/page/wiki/entities/dotta-b"');
});
it("applies wrap-friendly styles to long inline content", () => {
const html = renderMarkdown("averyveryveryveryveryveryveryveryveryverylongtoken");
expect(html).toContain('class="paperclip-markdown prose prose-sm min-w-0 max-w-full break-words overflow-hidden');
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
expect(html).toContain("<p");
});
it("applies wrap-friendly styles to long links", () => {
const html = renderMarkdown("[link](https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong)");
expect(html).toContain('<a href="https://example.com/reallyreallyreallyreallyreallyreallyreallyreallylong"');
expect(html).toContain('style="overflow-wrap:anywhere;word-break:break-word"');
});
it("renders markdown tables in a horizontally scrollable region", () => {
const html = renderMarkdown([
"| Time UTC | Source | Finding | Stalled leaf | Escalation |",
"| --- | --- | --- | --- | --- |",
"| 2026-04-30T14:31:35Z | PAP-2505 | in_review_without_action_path | PAP-2779 | PAP-2910 |",
].join("\n"));
expect(html).toContain('class="paperclip-markdown-table-scroll"');
expect(html).toContain('aria-label="Scrollable table"');
expect(html).toContain('tabindex="0"');
expect(html).toContain("<table>");
expect(html).toContain('style="overflow-wrap:anywhere;word-break:normal"');
});
it("opens external links in a new tab with safe rel attributes", () => {
const html = renderMarkdown("[docs](https://example.com/docs)");
expect(html).toContain("<a href=\"https://example.com/docs\"");
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
expect(html).not.toContain("lucide-github");
expect(html).toContain("lucide-external-link");
expect(html).toMatch(/<span style="white-space:nowrap">s<svg[^>]*lucide-external-link/);
});
it("opens GitHub links in a new tab", () => {
const html = renderMarkdown("[pr](https://github.com/paperclipai/paperclip/pull/4099)");
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
it("does not set target on relative internal links", () => {
const html = renderMarkdown("[settings](/company/settings)");
expect(html).toContain('href="/company/settings"');
expect(html).not.toContain('target="_blank"');
expect(html).toContain('rel="noreferrer"');
});
it("gives pull request URLs a compact label and a distinct icon", () => {
const href = "https://github.com/paperclipai/paperclip/pull/4099";
const html = renderMarkdown(`[${href}](${href})`);
expect(html).toContain(`<a href="${href}"`);
expect(html).toContain('data-github-link="pull_request"');
expect(html).toContain("lucide-git-pull-request");
expect(html).toContain("GitHub pull request:");
expect(html).toMatch(/<span style="white-space:nowrap">.*lucide-git-pull-request.*?<\/svg>p<\/span>/);
expect(html.replace(/<[^>]*>/g, "")).toContain("paperclipai/paperclip#4099");
expect(html).not.toContain("lucide-external-link");
});
it("gives issue autolinks their own icon and keeps the exact destination", () => {
const html = renderMarkdown("See https://github.com/paperclipai/paperclip/issues/1778?source=chat#issuecomment-123");
expect(html).toContain('href="https://github.com/paperclipai/paperclip/issues/1778?source=chat#issuecomment-123"');
expect(html).toContain('data-github-link="issue"');
expect(html).toContain("lucide-circle-dot");
expect(html).toContain("GitHub issue:");
expect(html.replace(/<[^>]*>/g, "")).toContain("paperclipai/paperclip#1778");
});
it.each([
["https://github.com/acme/web/pull/241", "acme/web#241"],
["https://github.com/acme/web/issues/42", "acme/web#42"],
["https://github.com/acme/web", "acme/web"],
])("compacts streaming URL text while preserving its word wrapper: %s", (href, label) => {
const html = renderMarkdown(href, [], {}, true);
expect(html).toContain(`class="conversation-stream-word">${label}</span>`);
expect(html).toContain(`href="${href}"`);
expect(html.replace(/<[^>]*>/g, "")).not.toContain(href);
});
it("preserves custom streaming labels and formatted URL labels", () => {
const href = "https://github.com/acme/web/pull/241";
const html = renderMarkdown(`[Review **the PR**](${href}) and [**${href}**](${href})`, [], {}, true);
expect(html).toContain("<strong>");
expect(html).toContain('class="conversation-stream-word"');
const text = html.replace(/<[^>]*>/g, "");
expect(text).toContain("Review the PR");
expect(text).toContain(href);
expect(text).not.toContain("acme/web#241");
});
it("keeps custom labels, formatted text, and authored titles", () => {
const html = renderMarkdown('[**Review** the PR](https://github.com/acme/web/pull/241 "Ready for review")');
expect(html).toContain("<strong>Review</strong> the PR");
expect(html).toContain('title="Ready for review"');
expect(html).toContain('data-github-link="pull_request"');
expect(html).toContain("lucide-git-pull-request");
expect(html).not.toContain("acme/web#241");
});
it("keeps GitHub branding for repositories and other GitHub pages", () => {
const html = renderMarkdown("https://github.com/acme/web and [GitHub Actions](https://github.com/features/actions)");
expect(html).toContain('data-github-link="repository"');
expect(html.replace(/<[^>]*>/g, "")).toContain("acme/web");
expect(html).toContain('data-github-link="github"');
expect(html).toContain("lucide-github");
});
it.each([
"https://github.com/stars/torvalds",
"https://github.com/education/teachers",
"https://github.com/enterprises/acme",
])("keeps GitHub site pages as branded full URL links: %s", (href) => {
const html = renderMarkdown(href);
expect(html).toContain('data-github-link="github"');
expect(html).not.toContain('data-github-link="repository"');
expect(html.replace(/<[^>]*>/g, "")).toContain(href);
expect(html).toContain(`href="${href}"`);
});
it("keeps full URL labels on lookalike hosts", () => {
const html = renderMarkdown("https://github.com.example.com/acme/web/pull/241");
expect(html).not.toContain("data-github-link");
expect(html).toContain("lucide-external-link");
expect(html.replace(/<[^>]*>/g, "")).toContain("https://github.com.example.com/acme/web/pull/241");
});
;
;
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```");
expect(html).toContain("<pre");
expect(html).toContain('style="max-width:100%;overflow-x:auto"');
});
it("renders a copy button alongside fenced code blocks", () => {
const html = renderMarkdown("```ts\nconst a = 1;\n```");
expect(html).toContain("paperclip-markdown-codeblock");
expect(html).toContain("paperclip-markdown-codeblock-actions");
expect(html).toContain("position:absolute;top:0.4rem;right:0.4rem;display:inline-flex");
expect(html).toContain("paperclip-markdown-codeblock-wrap");
expect(html).toContain('aria-label="Wrap lines"');
expect(html).toContain("position:static;opacity:1;display:inline-flex");
expect(html).toContain("paperclip-markdown-codeblock-copy");
expect(html).toContain('aria-label="Copy code"');
expect(html).toContain("lucide-copy");
});
it("renders code block actions for indented preformatted markdown blocks", () => {
const html = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox");
expect(html).toContain("paperclip-markdown-codeblock");
expect(html).toContain("paperclip-markdown-codeblock-wrap");
expect(html).toContain('aria-label="Wrap lines"');
expect(html).toContain("paperclip-markdown-codeblock-copy");
});
it("does not render a copy button on inline code", () => {
const html = renderMarkdown("Reference `inline-code` here.");
expect(html).not.toContain("paperclip-markdown-codeblock-copy");
});
it("renders internal issue links and bare identifiers as inline issue refs", () => {
const html = renderMarkdown(`See PAP-42 and [linked task](${buildIssueReferenceHref("PAP-77")}) for follow-up.`, [
{ identifier: "PAP-42", status: "done" },
{ identifier: "PAP-77", status: "blocked" },
]);
expect(html).toContain('href="/issues/PAP-42"');
expect(html).toContain('href="/issues/PAP-77"');
expect(html).toContain('data-mention-kind="issue"');
expect(html).toContain("paperclip-markdown-issue-ref");
expect(html).not.toContain("paperclip-mention-chip--issue");
});
it("gates bare-identifier auto-linking to known company prefixes", () => {
mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "PAP" }] });
const html = renderMarkdown("Depends on PAP-1271 and blocked by JIRA-2.", [
{ identifier: "PAP-1271", status: "done" },
{ identifier: "JIRA-2", status: "done" },
]);
// Known prefix links; foreign tracker key stays as plain text.
expect(html).toContain('href="/issues/PAP-1271"');
expect(html).not.toContain('href="/issues/JIRA-2"');
expect(html).toContain("blocked by JIRA-2.");
});
it("stays permissive when companies are loaded but the list is empty", () => {
mockUseOptionalCompany.mockReturnValue({ companies: [] });
const html = renderMarkdown("See JIRA-2 for context.", [
{ identifier: "JIRA-2", status: "done" },
]);
expect(html).toContain('href="/issues/JIRA-2"');
});
it("renders the inline mention status glyph at md (16px / h-4 w-4)", () => {
const html = renderMarkdown("See PAP-1271 for context.", [
{ identifier: "PAP-1271", status: "in_progress" },
]);
// Unified glyph at 16px (PAP-349 round 4: stepped down from lg), with the
// h-4 w-4 class override so the Tailwind sizing matches the intrinsic SVG
// size.
expect(html).toContain('viewBox="0 0 24 24"');
expect(html).toContain('width="16"');
expect(html).toContain('height="16"');
expect(html).toContain("h-4");
expect(html).toContain("w-4");
// PAP-243b: the glyph is optically centered to the body text
// (vertical-align: middle + a 1px lift), not floating off the baseline.
expect(html).toContain("align-middle");
expect(html).not.toContain("align-(--va-0_125em)");
// Legacy h-3 w-3 sizing is gone.
expect(html).not.toContain("mr-1 h-3 w-3");
});
it("never gates explicit internal issue paths, even for unknown prefixes", () => {
mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "PAP" }] });
const html = renderMarkdown("See /ACME/issues/ACME-1 for the writeup.", [
{ identifier: "ACME-1", status: "done" },
]);
expect(html).toContain('href="/issues/ACME-1"');
});
});