Files
PaperClipAI/ui/src/components/MarkdownBody.test.tsx
T
DottaandClaude Opus 5.5 c6781f5b2c fix(chat): keep company links written as //PREFIX/... inside the app (#19986)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - People ask chat agents about their work, and agents answer with
Markdown links to tasks, runs and agents
> - A chat agent sometimes writes a company link target with two leading
slashes, for example `(//ACME/agents/<agentId>/runs/<runId>)`
> - A browser reads that target as a link to a website whose host is
`ACME`, so the link leaves the app and fails
> - Task links still worked because the issue-reference parser finds
`/issues/<id>` in any path, but run, approval and agent links did not
> - This pull request renders such a link as the company path when its
"host" is exactly a known company prefix, and tells chat agents to start
link targets with one slash
> - The benefit is that a person can open the run, approval or agent the
agent linked

## Linked Issues or Issue Description

No public issue exists. Related, but different: #17864 (same-origin
absolute links).

**What happened**
In a chat-feature eval, a person asked what happened on a failed website
attempt. The agent read the run, explained the failure and linked both
runs as `[...](//ACME/agents/<agentId>/runs/<runId>)`. The rendered
anchors kept `href="//ACME/..."`, which opens `http://acme/...`. The
agent made the same `//` slip in an earlier attempt of the same case.

**Expected behavior**
A link to a company page opens that page in the app. A target like
`//ACME/...`, where `ACME` is a company prefix, can only mean the
company path `/ACME/...`.

**Steps to reproduce**
1. Render Markdown `[Failed run](//ACME/agents/a/runs/r)` in a chat or
comment while the viewer belongs to company prefix `ACME`.
2. Inspect the anchor.
3. See `href="//ACME/agents/a/runs/r"`. Click it, and the browser goes
to host `acme`.

## What Changed

- `ui/src/components/MarkdownBody.tsx`: add
`companyPathFromProtocolRelativeHref`. When a link target is
`//<host>...` and `<host>` is exactly one of the viewer's company
prefixes, render it as `/<host>...` (query and fragment kept). Other
protocol-relative links, absolute URLs and renders without known
prefixes stay unchanged.
- `server/src/services/agent-conversations.ts`: chat guidance now says
to start each link target with exactly one slash, because a `//` target
points to a different website.
- Tests for both.

## Verification

- `cd ui && pnpm exec vitest run src/components/MarkdownBody.test.tsx`:
69 passed. With the normalization removed, the new company-path test
fails (red/green checked).
- `server/src/__tests__/agent-conversations.test.ts` gets one added
`toContain` assertion for the new guidance sentence. I could not run
that suite locally in this worktree (its linked workspace packages are
from an older checkout), so CI supplies it.
- End to end: the change is cherry-picked onto a chat-eval case branch
and run through the remote `runs-adapters.inspect-failed-run` eval.

## Risks

- Low risk. The rewrite applies only when the protocol-relative host
matches a company prefix the viewer already has, exactly and
case-sensitively. A real single-label intranet host with the same
uppercase name as a company prefix would now open the company path
instead. Chat guidance already forbids invented hostnames for Paperclip
links.
- Guidance text grows by one sentence.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.

## 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
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:42:00 -06:00

864 lines
36 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 { companyPathFromProtocolRelativeHref, MarkdownBody } from "./MarkdownBody";
import { StatusIcon } from "./StatusIcon";
import { taskStatusIconProps } from "../lib/task-status-presentation";
import type { IssueBlockerAttention } from "@paperclipai/shared";
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; blockerAttention?: IssueBlockerAttention }> = [],
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,
...(issue.blockerAttention ? { blockerAttention: issue.blockerAttention } : {}),
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownWordStreaming.Provider value={streaming}>
<MarkdownBody {...props}>{children}</MarkdownBody>
</MarkdownWordStreaming.Provider>
</ThemeProvider>
</QueryClientProvider>,
);
}
describe("MarkdownBody", () => {
it("renders a company link written as //PREFIX/... as that company path", () => {
mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "RUN" }] });
const html = renderMarkdown("[Failed run](//RUN/agents/agent-1/runs/run-1)");
expect(html).toContain('href="/RUN/agents/agent-1/runs/run-1"');
expect(html).not.toContain('href="//RUN');
expect(html).not.toContain('target="_blank"');
});
it("keeps the query and fragment of a normalized company link", () => {
expect(companyPathFromProtocolRelativeHref("//RUN/issues/RUN-1#document-plan", ["RUN"])).toBe("/RUN/issues/RUN-1#document-plan");
expect(companyPathFromProtocolRelativeHref("//RUN", ["RUN"])).toBe("/RUN");
expect(companyPathFromProtocolRelativeHref("/RUN/issues/RUN-1", ["RUN"])).toBe("/RUN/issues/RUN-1");
expect(companyPathFromProtocolRelativeHref("https://RUN/x", ["RUN"])).toBe("https://RUN/x");
});
it("leaves protocol-relative links to other hosts unchanged", () => {
mockUseOptionalCompany.mockReturnValue({ companies: [{ issuePrefix: "RUN" }] });
expect(renderMarkdown("[Docs](//docs.example.com/RUN/guide)")).toContain('href="//docs.example.com/RUN/guide"');
expect(renderMarkdown("[Other](//OTHER/agents/a/runs/r)")).toContain('href="//OTHER/agents/a/runs/r"');
mockUseOptionalCompany.mockReturnValue(null);
expect(renderMarkdown("[Unknown](//RUN/agents/a/runs/r)")).toContain('href="//RUN/agents/a/runs/r"');
});
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("draws a blocked task mention with the same glyph and colour token as the task list (BUG-07)", () => {
// A task blocked on active sub-tasks is "covered": the list, breadcrumb and
// sidebar draw the blue in-queue glyph. Inline mentions must match.
const blockerAttention: IssueBlockerAttention = {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 4,
coveredBlockerCount: 4,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
};
const html = renderMarkdown("This stays open until all four are done (BRA-1).", [
{ identifier: "BRA-1", status: "blocked", title: "Paperclip onboarding", blockerAttention },
]);
const listGlyph = renderToStaticMarkup(
<StatusIcon {...taskStatusIconProps({ status: "blocked", blockerAttention })} />,
);
expect(listGlyph).toContain("var(--status-task-icon-in_queue)");
expect(html).toContain('href="/issues/BRA-1"');
expect(html).toContain("var(--status-task-icon-in_queue)");
expect(html).not.toContain("var(--status-task-icon-blocked)");
expect(html).toContain('aria-label="Blocked · waiting on 4 active sub-tasks"');
});
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"');
});
});