mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## 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>
810 lines
33 KiB
TypeScript
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>{""}</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("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"');
|
|
});
|
|
|
|
});
|