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 ?