mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators talk to their agents on the issue detail page. An experimental "Chat-Style Tasks" view (#10606) makes that page read as a conversation instead of a ticket form. > - The first release of that view shipped with a plain-text composer, no visible narration while an agent works, and a desktop-only layout. > - Users write formatted replies, paste screenshots, and follow long agent runs from their phones. The experimental view should support all of that before it can graduate. > - This pull request is the next iteration of the same experiment: a rich-text composer with attachments, live-turn narration on the status line, cleaner settled-turn history, and a mobile layout. > - The benefit is a chat view that feels alive while the agent works and stays readable after it finishes, on desktop and mobile, still fully behind the existing opt-in flag. ## Linked Issues or Issue Description Refs #49 (chat with agents is a much-wanted feature). Refs #10606 (the merged first release of the experimental chat-style task view; this PR iterates on it). Related PRs found in the dedup search: - #8228 — open PR that polishes the classic issue chat composer. It targets the flag-off legacy path; this PR only changes the flag-on experimental view. - #10466 — merged blockquote-recovery fix in the shared MarkdownEditor. This PR now reuses that editor inside the chat composer. **What existing behavior does this improve?** The experimental "Chat-Style Tasks" view on the issue detail page (Settings → Experimental, `enableTaskChatRedesign`, default off). **Subsystem affected** UI (issue detail page, chat-style task view). **Current behavior** With the experiment enabled, the composer is a plain textarea with no formatting, no attachment preview, and no mention support. While an agent runs, the status line shows only a static label, and the agent's narration text is hidden. Finished runs render one settled row per turn, so a run with many short turns produces a long list of near-duplicate "Worked" rows, and turns without a comment append at the bottom out of order. On mobile, the desktop bounded-height thread makes the page scroll poorly. **Proposed behavior** The composer uses the shared MarkdownEditor: markdown formatting, mentions, image paste with thumbnail previews, and non-image attachment chips. Sending posts on Cmd/Ctrl+Enter. While an agent runs, the status line rotates playful status words and surfaces the agent's own narration as short interstitial updates: each update holds for a minimum dwell, is replaced only when superseded, and slides through a one-line viewport with tokenized motion. Back-to-back settled turns coalesce into one "Worked" row with summed durations and re-derived tool counts, and comment-less settled turns insert chronologically at their run's start time. On mobile, the thread renders in the document flow with window auto-follow and a sticky safe-area composer; the desktop layout is unchanged. **Reason and benefit** The chat view is only convincing if it feels like a conversation with a working agent. Rich text and screenshots are table stakes for chat input. Live narration gives moment-to-moment feedback without opening transcripts. Coalesced history keeps long-running tasks readable. Mobile support lets operators follow runs away from their desks. **Breaking changes** None. Every change is gated behind the existing `enableTaskChatRedesign` flag, which is off by default. The flag-off page is unchanged. ## What Changed - `TaskChatComposer` swaps its textarea for the shared `MarkdownEditor`: markdown formatting, mentions, image paste with object-URL thumbnail previews (revoked on clear and unmount), and posting on Cmd/Ctrl+Enter. - Non-image attachments render as chips on a new shared `ui/attachment.tsx` primitive (adds the `@base-ui/react` dependency it builds on). - New `status-whimsy.ts`: deterministic rotation of playful status words on the live status line. - Live interstitial narration: the transcript adapter tags agent self-talk, and the live status line shows it as ephemeral one-line updates with a ~4s minimum dwell, hold-until-superseded replacement, and a slide transition driven by new `--motion-line-scroll` tokens (cataloged in `motion-tokens.ts`, which a test keeps 1:1 with `index.css`). Hover affordance applies only to the status line, with no leading icon. - Settled-turn history: `coalesceSettledTurns` merges back-to-back settled agent turns into one "Worked" row (summed per-run durations, tool counts re-derived from the merged turn); `assembleThreadItems` inserts comment-less settled turns chronologically at run start instead of appending them at the bottom; settled turns render tool rows only (the separate thinking block component is removed). - The "Worked" summary attaches to the reply timestamp row, and thread timestamps are always visible. - Mobile layout: the thread renders with `scroll={false}` in the page scroll, a new `useWindowAutoFollow` hook keeps the window pinned to new content, the composer is sticky with safe-area padding, and the editor uses 16px text so iOS does not zoom on focus. The desktop bounded chain is untouched. - New `TaskChatDescriptionBubble` renders the issue description as the first chat bubble, and `McpIcon` gives MCP tools a distinct icon. - `IssueDetail.test.tsx` stubs `TaskChatThread`: the composer's `@mdxeditor` dependency cannot load under jsdom's CSSOM, and the suite exercises the flag-off path. ## Verification - `pnpm check:token-gates` — 3/3 CLEAN. - `node scripts/check-task-chat-motion.mjs` — OK (30 files scanned, seams present). - `cd ui && npx tsc -b` — clean. - `cd ui && pnpm vitest run` — 3,482 of 3,483 tests pass locally. The one failure is the `IssueProperties.test.tsx` monitor-row time-formatting test, which is timezone-sensitive: it fails identically on unmodified `origin/master` in a non-UTC timezone and passes with `TZ=UTC`. It is not related to this change. - Manual: enable "Chat-Style Tasks" in Settings → Experimental and open an issue with an assigned agent. Comment to start a run: the status line rotates status words and shows the agent's narration as short held updates. After the run, consecutive turns fold into one "Worked" row under the reply timestamp. Paste an image into the composer to see a thumbnail chip; attach a non-image file to see a file chip; send with Cmd+Enter. Open the same issue in a narrow viewport to see the document-flow layout with the sticky composer. - Visual snapshot baselines are intentionally not updated: per `doc/design/DECISION-SHEET.md`, "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks - The composer now loads the shared MarkdownEditor inside the chat view. The editor is already used across the app (issue descriptions, comments), so its behavior is well exercised; composer-specific handling (paste, attachments, submit keys) is covered by new tests. - The transcript adapter changes how live narration and settled turns are derived from run logs. Malformed or legacy logs degrade to generic rows rather than crashing, and the adapter suites cover the merge and ordering rules. - Object URLs for paste previews are revoked on send-clear and unmount to avoid leaks; jsdom environments without `URL.createObjectURL` are guarded. - All changes are behind the default-off `enableTaskChatRedesign` flag. Overall risk with the flag off is low. ## Model Used - Claude (Anthropic), model id `claude-fable-5` (Claude Fable 5), extended thinking enabled, agentic tool use (file editing, shell, test execution) via Claude Code / Claude Agent SDK. ## 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 - [x] 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> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
619 lines
25 KiB
TypeScript
619 lines
25 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,
|
|
} from "@paperclipai/shared";
|
|
import { ThemeProvider } from "../context/ThemeContext";
|
|
import { MarkdownBody } from "./MarkdownBody";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
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 }> } | null>(() => null));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useOptionalCompany: mockUseOptionalCompany,
|
|
}));
|
|
|
|
afterEach(() => {
|
|
mockUseOptionalCompany.mockReturnValue(null);
|
|
});
|
|
|
|
function renderMarkdown(
|
|
children: string,
|
|
seededIssues: Array<{ identifier: string; status: string; title?: string }> = [],
|
|
props: Partial<ComponentProps<typeof MarkdownBody>> = {},
|
|
) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
retry: false,
|
|
},
|
|
},
|
|
});
|
|
|
|
for (const issue of seededIssues) {
|
|
queryClient.setQueryData(queryKeys.issues.detail(issue.identifier), {
|
|
id: issue.identifier,
|
|
identifier: issue.identifier,
|
|
status: issue.status,
|
|
title: issue.title,
|
|
});
|
|
}
|
|
|
|
return renderToStaticMarkup(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider>
|
|
<MarkdownBody {...props}>{children}</MarkdownBody>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe("MarkdownBody", () => {
|
|
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 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-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("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("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" }],
|
|
{ linkWorkspaceFileRefs: true },
|
|
);
|
|
|
|
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("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('href="https://example.com/docs"');
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
});
|
|
|
|
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("prefixes GitHub markdown links with the GitHub icon glued to the first character", () => {
|
|
const html = renderMarkdown("[https://github.com/paperclipai/paperclip/pull/4099](https://github.com/paperclipai/paperclip/pull/4099)");
|
|
|
|
expect(html).toContain('<a href="https://github.com/paperclipai/paperclip/pull/4099"');
|
|
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)"');
|
|
// The icon and first character "h" must sit in a no-wrap span so the
|
|
// icon can never be orphaned on the previous line from the URL text.
|
|
expect(html).toMatch(/<span style="white-space:nowrap">.*lucide-github.*?<\/svg>h<\/span>/);
|
|
expect(html).toContain("ttps://github.com/paperclipai/paperclip/pull/4099");
|
|
expect(html).not.toContain("lucide-external-link");
|
|
});
|
|
|
|
it("prefixes GitHub autolinks with the GitHub icon", () => {
|
|
const html = renderMarkdown("See https://github.com/paperclipai/paperclip/issues/1778");
|
|
|
|
expect(html).toContain('<a href="https://github.com/paperclipai/paperclip/issues/1778"');
|
|
expect(html).toContain('class="lucide lucide-github mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)"');
|
|
});
|
|
|
|
it("does not prefix non-GitHub markdown links with the GitHub icon", () => {
|
|
const html = renderMarkdown("[docs](https://example.com/docs)");
|
|
|
|
expect(html).toContain('<a href="https://example.com/docs"');
|
|
expect(html).not.toContain("lucide-github");
|
|
});
|
|
|
|
it("suffixes external links with a new-tab icon glued to the last character", () => {
|
|
const html = renderMarkdown("[docs](https://example.com/docs)");
|
|
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain("lucide-external-link");
|
|
// Last character "s" must sit in a no-wrap span with the icon so the
|
|
// indicator never wraps away from the link text.
|
|
expect(html).toMatch(/<span style="white-space:nowrap">s<svg[^>]*lucide-external-link/);
|
|
});
|
|
|
|
it("does not render the new-tab icon on internal links", () => {
|
|
const html = renderMarkdown("[settings](/company/settings)");
|
|
|
|
expect(html).not.toContain("lucide-external-link");
|
|
});
|
|
|
|
it("keeps fenced code blocks width-bounded and horizontally scrollable", () => {
|
|
const html = renderMarkdown("```text\nGET /heartbeat-runs/ca5d23fc-c15b-4826-8ff1-2b6dd11be096/log?offset=2062357&limitBytes=256000\n```");
|
|
|
|
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"');
|
|
});
|
|
|
|
});
|