mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 14:19:00 +01:00
## 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>
864 lines
36 KiB
TypeScript
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>{""}</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}`}>
|
|
{""}
|
|
</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\n\n\n\n',
|
|
[], { 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("<script>");
|
|
expect(html).toContain("onclick="steal()"");
|
|
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("<!--");
|
|
expect(html).not.toContain("-->");
|
|
});
|
|
|
|
it("hides escaped HTML comment placeholders before attachment images", () => {
|
|
const html = renderMarkdown("\\<!-- --> ");
|
|
|
|
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
|
|
expect(html).not.toContain("<!--");
|
|
expect(html).not.toContain("-->");
|
|
});
|
|
|
|
it("hides incomplete streamed HTML comment placeholders before attachment images", () => {
|
|
const html = renderMarkdown("\\<!-- ");
|
|
|
|
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
|
|
expect(html).not.toContain("<!--");
|
|
});
|
|
|
|
it("hides incomplete encoded HTML comment placeholders", () => {
|
|
const html = renderMarkdown("<!-- -- ");
|
|
|
|
expect(html).toContain('<img src="/api/attachments/57d0805a-1b95-4fa5-abb4-d0c33e2e649c/content" alt=""/>');
|
|
expect(html).not.toContain("<!--");
|
|
expect(html).not.toContain("&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("<!-- -->");
|
|
expect(blockHtml).toContain("<!-- keep this example -->");
|
|
});
|
|
|
|
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("[&_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"');
|
|
});
|
|
|
|
});
|