mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
feat(ui): distinguish GitHub resources in inline Markdown links (#19476)
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Task conversations and documents use the shared Markdown renderer. > - GitHub repository, pull request, and issue links currently use the same icon. > - Long pasted URLs make these references hard to scan in a conversation. > - This change identifies each resource from its URL and shows a compact label with a distinct icon. > - The exact destination and custom link text stay intact. Connected status data keeps its existing behavior. ## Linked Issues or Issue Description **What existing behavior does this improve?** Inline GitHub links in rendered Markdown. **Current behavior** Repositories, pull requests, and issues all use the GitHub mark. Pasted resource links show the full URL. **Proposed behavior** Pull requests use the pull request icon. Issues use the issue icon. Repositories and other GitHub pages keep the brand mark. Pasted resource URLs show `owner/repository#number` or `owner/repository`. Custom labels keep their text and formatting. **Reason and benefit** Readers can identify GitHub resource types and scan references in a narrow conversation. Full URLs, query strings, and comment anchors stay available through the link destination. Link rendering makes no remote metadata requests. **Breaking changes** The visible text of a bare GitHub resource URL becomes compact. The destination does not change. No API, database, or connection changes are required. Searches found no duplicate work for GitHub inline resource presentation. Related work: [same-origin Markdown routing](https://github.com/paperclipai/paperclip/pull/17864). This change preserves that route handling. ## What Changed - Add a pure GitHub URL classifier with exact host checks and resource labels. - Move ordinary URL presentation into `MarkdownUrlLink`. Keep internal references, workspace links, custom resolvers, and connected metadata in their existing order. - Add separate icons and accessible resource names. Preserve authored Markdown titles and formatted labels. - Add Playground, Narrow, and Streaming Storybook examples that mount `MarkdownBody`. - Add parser and renderer tests, eight Chromium browser cases, and the browser command in the development guide. - Consider Open Graph loading. Use the existing connected metadata path for remote status and titles. Add no arbitrary page fetches. ## Verification - Focused Vitest coverage: 129 passing tests across nine files for GitHub parsing, Markdown rendering, connected metadata, document rendering, and workspace-file rendering. - `pnpm check:token-gates`. - `pnpm --filter @paperclipai/ui typecheck`. - `pnpm --filter @paperclipai/ui build`. - `pnpm build-storybook`. - Storybook catalog generation/check and 30 catalog/map tests. - Site-route regression: Stars, Education, and enterprise pages keep general GitHub branding and their full URL text. Three parser cases failed before the fix; parser and renderer cases now pass. - Streaming regression: three resource cases failed before the fix and pass after it. The fix preserves the generated word span and custom formatted labels. - Chromium: `STORYBOOK_TEST_BASE_URL=http://localhost:6106 pnpm exec playwright test inline-links.spec.ts --config=tests/storybook-visual/playwright.config.ts --project=stories`. - Browser scope: eight passing cases for ordinary/streaming modes, dark/light at desktop and 390px mobile, completed play assertions, exact comment destinations, safe external attributes, keyboard focus, wrapping, and no GitHub requests. Screenshots are saved as task artifacts; no review images are committed. - The first browser attempt in the resumed environment could not launch Chromium because its executable was missing. The browser was installed, and all eight cases then passed. - The browser cases use synthetic text and one connected metadata fixture. They do not prove provider authentication or server persistence. No visual baseline comparison is claimed; the repository baseline manifest is unpublished. - Initial typecheck found two unsupported story assertion options and an unbuilt SDK dependency in the fresh checkout. The options were removed and the SDK was built before the successful retry. - `pnpm verify` returned 2 because the new browser spec requires broad CI coverage. Focused tests and the browser suite supply local evidence. Required CI supplies broad coverage. ## Risks - Compact labels hide URL detail from the visible line. The exact href and default tooltip preserve that detail. - GitHub site routes can change. Unknown paths retain the general GitHub treatment. Lookalike hosts and credential-bearing URLs do not get GitHub resource treatment. - Connected external-object status and title rendering keeps priority over URL-derived presentation. ## Model Used OpenAI Codex, GPT-6 (`gpt-6` family), with reasoning, tool use, browser checks, and code execution. The exact deployment ID and context window are not exposed in this run. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b6e9615aed
commit
6148fbd31f
8 files changed
+385
-104
No files matched your search
@@ -522,6 +522,16 @@ pnpm build-storybook
|
||||
|
||||
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
|
||||
|
||||
**Inline links** mounts the shared Markdown renderer with GitHub repository,
|
||||
pull request, and issue references, custom labels, connected status metadata,
|
||||
generic URL fallbacks, and streaming playback. Pasted GitHub resource URLs use compact labels;
|
||||
the exact destination, query, and fragment stay intact. The presentation makes
|
||||
no remote metadata requests. Run its light/dark desktop/mobile checks with:
|
||||
|
||||
```sh
|
||||
STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright test inline-links.spec.ts --config=tests/storybook-visual/playwright.config.ts --project=stories
|
||||
```
|
||||
|
||||
**Task chat → Failure notices** covers compact run errors and adjacent recovery
|
||||
notices in both presentation modes, with expanded details, fixture retry
|
||||
callbacks (including the legacy run retry when recovery retry is unavailable),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const theme of ["dark", "light"]) {
|
||||
for (const mobile of [false, true]) {
|
||||
for (const streaming of [false, true]) {
|
||||
test(`GitHub inline links [${theme}, ${mobile ? "mobile" : "desktop"}, ${streaming ? "streaming" : "ordinary"}]`, async ({ page }, testInfo) => {
|
||||
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
|
||||
const errors: string[] = [];
|
||||
const providerRequests: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).hostname === "github.com") providerRequests.push(request.url());
|
||||
});
|
||||
const id = `tasks-inline-links--${streaming ? "streaming" : mobile ? "narrow" : "playground"}`;
|
||||
await page.goto(`/iframe.html?id=${id}&viewMode=story&globals=theme:${theme}`);
|
||||
await expect.poll(() => page.evaluate(() => {
|
||||
const channel = (window as unknown as {
|
||||
__STORYBOOK_ADDONS_CHANNEL__?: { data: Record<string, Array<{ status?: string }>> };
|
||||
}).__STORYBOOK_ADDONS_CHANNEL__;
|
||||
return channel?.data.storyFinished?.[0]?.status;
|
||||
})).toBe("success");
|
||||
const main = page.getByRole("main");
|
||||
const pr = main.getByRole("link", { name: "GitHub pull request: acme/web#241", exact: true });
|
||||
await expect(pr).toHaveAttribute("rel", "noopener noreferrer");
|
||||
if (streaming) {
|
||||
await expect(pr.locator(".conversation-stream-word")).toHaveText("acme/web#241");
|
||||
}
|
||||
const comment = main.getByRole("link", { name: "GitHub pull request: Jump to the review comment" });
|
||||
await expect(comment).toHaveAttribute("href", "https://github.com/acme/web/pull/241/files?diff=split#discussion_r123");
|
||||
await expect(comment).toHaveAttribute("title", "Review comment on the exact diff");
|
||||
await pr.focus();
|
||||
await expect(pr).toBeFocused();
|
||||
await pr.blur();
|
||||
const settings = main.getByRole("link", { name: "company settings" });
|
||||
await expect(settings).not.toHaveAttribute("target");
|
||||
await expect(main.locator("a[data-github-link] .lucide-external-link")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
expect(errors).toEqual([]);
|
||||
expect(providerRequests).toEqual([]);
|
||||
await page.screenshot({ path: testInfo.outputPath(`inline-links-${theme}-${mobile ? "mobile" : "desktop"}-${streaming ? "streaming" : "ordinary"}.png`), fullPage: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} 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";
|
||||
|
||||
@@ -66,6 +67,7 @@ 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: {
|
||||
@@ -87,7 +89,9 @@ function renderMarkdown(
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<MarkdownBody {...props}>{children}</MarkdownBody>
|
||||
<MarkdownWordStreaming.Provider value={streaming}>
|
||||
<MarkdownBody {...props}>{children}</MarkdownBody>
|
||||
</MarkdownWordStreaming.Provider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -607,23 +611,85 @@ describe("MarkdownBody", () => {
|
||||
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)");
|
||||
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="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).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("prefixes GitHub autolinks with the GitHub icon", () => {
|
||||
const html = renderMarkdown("See https://github.com/paperclipai/paperclip/issues/1778");
|
||||
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('<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)"');
|
||||
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");
|
||||
});
|
||||
|
||||
;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MarkdownWordStreaming, rehypeStreamingWords } from "./MarkdownWordStreaming";
|
||||
import { isValidElement, memo, useContext, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, ExternalLink, MessageCircle, WrapText } from "lucide-react";
|
||||
import { Check, Copy, MessageCircle, WrapText } from "lucide-react";
|
||||
import { useCoordinatorPresentation } from "./coordinator/CoordinatorPresentation";
|
||||
import { useCoordinatorChatReference } from "./coordinator/CoordinatorChatReferences";
|
||||
import { SidebarDocumentLink } from "./SidebarDocumentLink";
|
||||
@@ -9,7 +9,7 @@ import { parseDocumentAnnotationHash } from "@/lib/document-annotation-hash";
|
||||
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "../lib/utils";
|
||||
import { GithubIcon } from "@/components/icons/github-icon";
|
||||
import { MarkdownUrlLink } from "./MarkdownUrlLink";
|
||||
import { Link, useCaseHref } from "@/lib/router";
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useOptionalCompany } from "../context/CompanyContext";
|
||||
@@ -572,78 +572,6 @@ function createRemarkWikiLinks(options: {
|
||||
};
|
||||
}
|
||||
|
||||
function isGitHubUrl(href: string | null | undefined): boolean {
|
||||
if (!href) return false;
|
||||
try {
|
||||
const url = new URL(href);
|
||||
return url.protocol === "https:" && (url.hostname === "github.com" || url.hostname === "www.github.com");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isExternalHttpUrl(href: string | null | undefined): boolean {
|
||||
if (!href) return false;
|
||||
try {
|
||||
const url = new URL(href);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
if (typeof window === "undefined") return true;
|
||||
return url.origin !== window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLinkBody(
|
||||
children: ReactNode,
|
||||
leadingIcon: ReactNode,
|
||||
trailingIcon: ReactNode,
|
||||
): ReactNode {
|
||||
if (!leadingIcon && !trailingIcon) return children;
|
||||
|
||||
// React-markdown can pass arrays/elements for styled link text; the nowrap
|
||||
// splitting below is intentionally limited to plain text links.
|
||||
if (typeof children === "string" && children.length > 0) {
|
||||
if (children.length === 1) {
|
||||
return (
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{leadingIcon}
|
||||
{children}
|
||||
{trailingIcon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const first = children[0];
|
||||
const last = children[children.length - 1];
|
||||
const middle = children.slice(1, -1);
|
||||
return (
|
||||
<>
|
||||
{leadingIcon ? (
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{leadingIcon}
|
||||
{first}
|
||||
</span>
|
||||
) : first}
|
||||
{middle}
|
||||
{trailingIcon ? (
|
||||
<span style={{ whiteSpace: "nowrap" }}>
|
||||
{last}
|
||||
{trailingIcon}
|
||||
</span>
|
||||
) : last}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{leadingIcon}
|
||||
{children}
|
||||
{trailingIcon}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({
|
||||
children,
|
||||
preProps,
|
||||
@@ -1009,24 +937,10 @@ function MarkdownBodyImpl({
|
||||
</Link>
|
||||
) : <>{linkChildren}</>;
|
||||
}
|
||||
const isGitHubLink = isGitHubUrl(href);
|
||||
const isExternal = isExternalHttpUrl(href);
|
||||
const leadingIcon = isGitHubLink ? (
|
||||
<GithubIcon aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)" />
|
||||
) : null;
|
||||
const trailingIcon = isExternal && !isGitHubLink ? (
|
||||
<ExternalLink aria-hidden="true" className="ml-1 inline h-3 w-3 align-(--va-0_125em)" />
|
||||
) : null;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
{...(isExternal
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: { rel: "noreferrer" })}
|
||||
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
||||
>
|
||||
{renderLinkBody(linkChildren, leadingIcon, trailingIcon)}
|
||||
</a>
|
||||
<MarkdownUrlLink href={href} title={anchorProps.title} style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}>
|
||||
{linkChildren}
|
||||
</MarkdownUrlLink>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cloneElement, isValidElement, type CSSProperties, type ReactNode } from "react";
|
||||
import { CircleDot, ExternalLink, GitPullRequest } from "lucide-react";
|
||||
import { GithubIcon } from "./icons/github-icon";
|
||||
import { parseGitHubLink } from "@/lib/github-link";
|
||||
|
||||
function isExternalHttpUrl(href: string | undefined): boolean {
|
||||
if (!href) return false;
|
||||
try {
|
||||
const url = new URL(href);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
||||
return typeof window === "undefined" || url.origin !== window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLinkBody(children: ReactNode, leadingIcon: ReactNode, trailingIcon: ReactNode): ReactNode {
|
||||
if (!leadingIcon && !trailingIcon) return children;
|
||||
if (typeof children !== "string" || !children.length) return <>{leadingIcon}{children}{trailingIcon}</>;
|
||||
if (children.length === 1) {
|
||||
return <span style={{ whiteSpace: "nowrap" }}>{leadingIcon}{children}{trailingIcon}</span>;
|
||||
}
|
||||
return <>
|
||||
{leadingIcon ? <span style={{ whiteSpace: "nowrap" }}>{leadingIcon}{children[0]}</span> : children[0]}
|
||||
{children.slice(1, -1)}
|
||||
{trailingIcon ? <span style={{ whiteSpace: "nowrap" }}>{children.at(-1)}{trailingIcon}</span> : children.at(-1)}
|
||||
</>;
|
||||
}
|
||||
|
||||
function compactUrlLabel(children: ReactNode, href: string | undefined, label: string): ReactNode {
|
||||
if (children === href) return label;
|
||||
const child = Array.isArray(children) && children.length === 1 ? children[0] : children;
|
||||
if (
|
||||
isValidElement<{ className?: string; children?: ReactNode }>(child)
|
||||
&& child.type === "span"
|
||||
&& child.props.className?.split(/\s+/).includes("conversation-stream-word")
|
||||
&& child.props.children === href
|
||||
) {
|
||||
return cloneElement(child, undefined, label);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export function MarkdownUrlLink({ href, children, title, style }: {
|
||||
href?: string;
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const github = parseGitHubLink(href);
|
||||
const external = isExternalHttpUrl(href);
|
||||
const Icon = github?.kind === "pull_request" ? GitPullRequest : github?.kind === "issue" ? CircleDot : GithubIcon;
|
||||
const label = github?.label ? compactUrlLabel(children, href, github.label) : children;
|
||||
const leadingIcon = github ? <Icon aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)" /> : null;
|
||||
const trailingIcon = external && !github ? <ExternalLink aria-hidden="true" className="ml-1 inline h-3 w-3 align-(--va-0_125em)" /> : null;
|
||||
return <a
|
||||
href={href}
|
||||
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : { rel: "noreferrer" })}
|
||||
data-github-link={github?.kind}
|
||||
title={title ?? (github ? `${github.description}\n${href}` : undefined)}
|
||||
style={style}
|
||||
>
|
||||
{github ? <span className="sr-only">{github.kind === "pull_request" ? "GitHub pull request: " : github.kind === "issue" ? "GitHub issue: " : "GitHub: "}</span> : null}
|
||||
{renderLinkBody(label, leadingIcon, trailingIcon)}
|
||||
</a>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseGitHubLink } from "./github-link";
|
||||
|
||||
describe("GitHub inline link presentation", () => {
|
||||
it.each([
|
||||
["https://github.com/github/docs", "repository", "github/docs"],
|
||||
["https://github.com/paperclipai/paperclip", "repository", "paperclipai/paperclip"],
|
||||
["https://github.com/paperclipai/paperclip/", "repository", "paperclipai/paperclip"],
|
||||
["https://github.com/acme/web/pull/241", "pull_request", "acme/web#241"],
|
||||
["HTTPS://WWW.GITHUB.COM/Acme/Web/pull/241/files?diff=split#diff-a", "pull_request", "Acme/Web#241"],
|
||||
["https://github.com/acme/web/issues/42#issuecomment-123", "issue", "acme/web#42"],
|
||||
["https://github.com/acme/web/issues/42/?q=test", "issue", "acme/web#42"],
|
||||
])("classifies %s", (href, kind, label) => {
|
||||
expect(parseGitHubLink(href)).toMatchObject({ kind, label });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://github.com", "https://github.com/paperclipai",
|
||||
"https://github.com/acme/web/tree/main", "https://github.com/acme/web/pull/nope",
|
||||
"https://github.com/acme/web/issues/0", "https://github.com/settings/profile",
|
||||
"https://github.com/features/actions", "https://github.com/orgs/acme",
|
||||
"https://github.com/stars/torvalds", "https://github.com/education/teachers",
|
||||
"https://github.com/enterprises/acme",
|
||||
"https://github.com/acme/web/pulls", "https://github.com/acme/web/issues",
|
||||
])("keeps GitHub pages without a resource match as branded links: %s", (href) => {
|
||||
expect(parseGitHubLink(href)).toEqual({ kind: "github", label: null, description: "GitHub" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined, "", "/acme/web/pull/241", "//github.com/acme/web/pull/241",
|
||||
"https://example.com/acme/web/pull/241", "https://github.com.example.com/acme/web/pull/241",
|
||||
"https://github.example.com/acme/web/pull/241", "https://github.com@evil.example/acme/web",
|
||||
"https://someone@github.com/acme/web", "https://github.com:8443/acme/web",
|
||||
"http://github.com/acme/web", "javascript:alert(1)", "not a URL",
|
||||
])("does not apply GitHub presentation to %s", (href) => {
|
||||
expect(parseGitHubLink(href)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
export interface GitHubLink {
|
||||
kind: "github" | "repository" | "pull_request" | "issue";
|
||||
label: string | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const RESERVED_PATHS = new Set([
|
||||
"about", "account", "apps", "business", "codespaces", "collections", "contact",
|
||||
"customer-stories", "education", "enterprise", "enterprises", "events", "explore", "features",
|
||||
"issues", "join", "login", "logout", "marketplace", "new", "notifications",
|
||||
"organizations", "orgs", "pricing", "pulls", "readme", "search", "security",
|
||||
"sessions", "settings", "site", "sponsors", "stars", "team", "topics", "trending", "users",
|
||||
]);
|
||||
|
||||
export function parseGitHubLink(href: string | null | undefined): GitHubLink | null {
|
||||
if (!href) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(href);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.protocol !== "https:"
|
||||
|| (url.hostname !== "github.com" && url.hostname !== "www.github.com")
|
||||
|| url.username || url.password || url.port
|
||||
) return null;
|
||||
|
||||
const fallback: GitHubLink = { kind: "github", label: null, description: "GitHub" };
|
||||
const segments = url.pathname.replace(/\/$/, "").slice(1).split("/");
|
||||
const [owner, repository, type, number] = segments;
|
||||
if (
|
||||
!owner || !repository || RESERVED_PATHS.has(owner.toLowerCase())
|
||||
|| !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(owner)
|
||||
|| !/^[a-z\d_.-]+$/i.test(repository)
|
||||
|| repository === "." || repository === ".."
|
||||
) return fallback;
|
||||
|
||||
const repo = `${owner}/${repository}`;
|
||||
if ((type === "pull" || type === "issues") && number && /^[1-9]\d*$/.test(number)) {
|
||||
const label = `${repo}#${number}`;
|
||||
return {
|
||||
kind: type === "pull" ? "pull_request" : "issue",
|
||||
label,
|
||||
description: `GitHub ${type === "pull" ? "pull request" : "issue"}: ${label}`,
|
||||
};
|
||||
}
|
||||
if (segments.length === 2) {
|
||||
return { kind: "repository", label: repo, description: `GitHub repository: ${repo}` };
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { MarkdownWordStreaming } from "@/components/MarkdownWordStreaming";
|
||||
import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
|
||||
|
||||
const references: MarkdownExternalReferenceMap = {
|
||||
"https://github.com/acme/web/pull/241": {
|
||||
providerKey: "github", objectType: "pull_request", displayKey: "GitHub pull request",
|
||||
statusCategory: "succeeded", statusIconKey: "git-merge", liveness: "fresh",
|
||||
statusLabel: "Merged", displayTitle: "Keep inline links readable",
|
||||
},
|
||||
};
|
||||
|
||||
function Sample({ label, markdown, resolved = false }: { label: string; markdown: string; resolved?: boolean }) {
|
||||
return <section className="flex min-w-0 flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-muted-foreground">{label}</h2>
|
||||
<MarkdownBody linkIssueReferences={false} externalReferences={resolved ? references : undefined}>{markdown}</MarkdownBody>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function InlineLinksGallery() {
|
||||
return <main className="mx-auto flex max-w-3xl flex-col gap-8 p-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<p className="text-xs text-muted-foreground">INLINE LINKS</p>
|
||||
<h1 className="text-2xl font-semibold">A little more context, right in the line.</h1>
|
||||
<p className="text-sm text-muted-foreground">GitHub references stay compact. Each resource has a familiar icon.</p>
|
||||
</header>
|
||||
<Sample label="In a conversation" markdown={
|
||||
"The fix is ready at https://github.com/acme/web/pull/241. It closes https://github.com/acme/web/issues/42 and updates https://github.com/acme/web. Please take a look before the next release."
|
||||
} />
|
||||
<Sample label="Repository · Pull request · Issue · GitHub" markdown={
|
||||
"https://github.com/paperclipai/paperclip\n\nhttps://github.com/paperclipai/paperclip/pull/4099\n\nhttps://github.com/paperclipai/paperclip/issues/1778\n\n[GitHub Actions](https://github.com/features/actions)"
|
||||
} />
|
||||
<Sample label="Your words stay yours" markdown={
|
||||
'[Review the changes](https://github.com/acme/web/pull/241) and [**read** the issue](https://github.com/acme/web/issues/42).\n\n[Jump to the review comment](https://github.com/acme/web/pull/241/files?diff=split#discussion_r123 "Review comment on the exact diff") or open the [repository](https://github.com/acme/web).'
|
||||
} />
|
||||
<Sample label="Connected metadata keeps its status and title" resolved markdown={
|
||||
"Already shipped: [Keep inline links readable](https://github.com/acme/web/pull/241)."
|
||||
} />
|
||||
<Sample label="Other destinations keep their usual treatment" markdown={
|
||||
"Read the [documentation](https://example.com/docs), open [company settings](/company/settings), or share https://github.com.example.com/acme/web/pull/241.\n\nCode stays literal: `https://github.com/acme/web/pull/241`."
|
||||
} />
|
||||
<Sample label="Long references still wrap" markdown={
|
||||
"Follow https://github.com/an-organization-with-a-long-name/a-repository-with-an-equally-long-name/pull/123456/files?diff=split#discussion_r123456."
|
||||
} />
|
||||
</main>;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "tasks-inline-links",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Inline links",
|
||||
component: InlineLinksGallery,
|
||||
parameters: {
|
||||
paperclip: {
|
||||
capabilities: ["tasks/discussion"],
|
||||
status: "current",
|
||||
implementation: "production",
|
||||
boundary: "fixtures",
|
||||
notes: "Mounts MarkdownBody with synthetic text and one host-resolved metadata fixture. Covers URL-derived GitHub repository, pull request and issue icons, authored labels, exact anchors, generic links, code, wrapping, streaming playback labels, and existing connected status. No remote metadata requests, provider actions, or production persistence.",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof InlineLinksGallery>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Playground: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const pr = canvas.getByRole("link", { name: "GitHub pull request: acme/web#241" });
|
||||
await expect(pr).toHaveAttribute("href", "https://github.com/acme/web/pull/241");
|
||||
await expect(pr).toHaveAttribute("target", "_blank");
|
||||
await expect(pr.querySelector(".lucide-git-pull-request")).not.toBeNull();
|
||||
const issue = canvas.getByRole("link", { name: "GitHub issue: acme/web#42" });
|
||||
await expect(issue.querySelector(".lucide-circle-dot")).not.toBeNull();
|
||||
await expect(canvas.getByRole("link", { name: "GitHub pull request: Review the changes" })).toBeVisible();
|
||||
await expect(canvas.getByRole("link", { name: "GitHub pull request Merged: Keep inline links readable" })).toHaveAttribute("data-external-status", "succeeded");
|
||||
await expect(canvas.getByText("https://github.com/acme/web/pull/241", { selector: "code" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const Narrow: Story = {
|
||||
...Playground,
|
||||
parameters: { waitForViewport: true },
|
||||
globals: { viewport: { value: "mobile1", isRotated: false } },
|
||||
};
|
||||
|
||||
export const Streaming: Story = {
|
||||
...Playground,
|
||||
decorators: [(Story) => <MarkdownWordStreaming.Provider value><Story /></MarkdownWordStreaming.Provider>],
|
||||
};
|
||||
Reference in new issue
Block a user