From 6148fbd31f587941e4d83933c9596f0d032ded52 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:26:10 -0600 Subject: [PATCH] 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 --- doc/DEVELOPING.md | 10 ++ tests/storybook-visual/inline-links.spec.ts | 44 +++++++++ ui/src/components/MarkdownBody.test.tsx | 92 +++++++++++++++--- ui/src/components/MarkdownBody.tsx | 96 +------------------ ui/src/components/MarkdownUrlLink.tsx | 66 +++++++++++++ ui/src/lib/github-link.test.ts | 38 ++++++++ ui/src/lib/github-link.ts | 52 ++++++++++ .../stories/tasks/inline-links.stories.tsx | 91 ++++++++++++++++++ 8 files changed, 385 insertions(+), 104 deletions(-) create mode 100644 tests/storybook-visual/inline-links.spec.ts create mode 100644 ui/src/components/MarkdownUrlLink.tsx create mode 100644 ui/src/lib/github-link.test.ts create mode 100644 ui/src/lib/github-link.ts create mode 100644 ui/storybook/stories/tasks/inline-links.stories.tsx diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index fd93ba96369..53215d9dd47 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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), diff --git a/tests/storybook-visual/inline-links.spec.ts b/tests/storybook-visual/inline-links.spec.ts new file mode 100644 index 00000000000..3a32d13e539 --- /dev/null +++ b/tests/storybook-visual/inline-links.spec.ts @@ -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> }; + }).__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 }); + }); + } + } +} diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 827943ecac0..dd5d6163149 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -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> = {}, + streaming = false, ) { const queryClient = new QueryClient({ defaultOptions: { @@ -87,7 +89,9 @@ function renderMarkdown( return renderToStaticMarkup( - {children} + + {children} + , ); @@ -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('.*lucide-github.*?<\/svg>h<\/span>/); - expect(html).toContain("ttps://github.com/paperclipai/paperclip/pull/4099"); + expect(html).toContain(`.*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(']*>/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}`); + 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(""); + 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("Review 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"); }); ; diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 2063d7485af..1d9d7cca954 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -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 ( - - {leadingIcon} - {children} - {trailingIcon} - - ); - } - const first = children[0]; - const last = children[children.length - 1]; - const middle = children.slice(1, -1); - return ( - <> - {leadingIcon ? ( - - {leadingIcon} - {first} - - ) : first} - {middle} - {trailingIcon ? ( - - {last} - {trailingIcon} - - ) : last} - - ); - } - - return ( - <> - {leadingIcon} - {children} - {trailingIcon} - - ); -} - function CodeBlock({ children, preProps, @@ -1009,24 +937,10 @@ function MarkdownBodyImpl({ ) : <>{linkChildren}; } - const isGitHubLink = isGitHubUrl(href); - const isExternal = isExternalHttpUrl(href); - const leadingIcon = isGitHubLink ? ( - - {renderLinkBody(linkChildren, leadingIcon, trailingIcon)} - + + {linkChildren} + ); }, }; diff --git a/ui/src/components/MarkdownUrlLink.tsx b/ui/src/components/MarkdownUrlLink.tsx new file mode 100644 index 00000000000..79995ecff2f --- /dev/null +++ b/ui/src/components/MarkdownUrlLink.tsx @@ -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 {leadingIcon}{children}{trailingIcon}; + } + return <> + {leadingIcon ? {leadingIcon}{children[0]} : children[0]} + {children.slice(1, -1)} + {trailingIcon ? {children.at(-1)}{trailingIcon} : 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 ?