diff --git a/site/package.json b/site/package.json index 7c256e374cdb1..dd0d022709f94 100644 --- a/site/package.json +++ b/site/package.json @@ -84,6 +84,7 @@ "humanize-duration": "3.34.0", "jszip": "3.10.1", "lexical": "0.44.0", + "linkifyjs": "4.3.3", "lodash": "4.18.1", "lucide-react": "0.555.0", "monaco-editor": "0.55.1", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 36e1eb8bcb884..1ca58e1534888 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: lexical: specifier: 0.44.0 version: 0.44.0 + linkifyjs: + specifier: 4.3.3 + version: 4.3.3 lodash: specifier: 4.18.1 version: 4.18.1 @@ -4491,6 +4494,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, tarball: https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==, tarball: https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz} + lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==, tarball: https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz} @@ -10658,6 +10664,8 @@ snapshots: lines-and-columns@1.2.4: {} + linkifyjs@4.3.3: {} + lodash-es@4.18.1: {} lodash@4.18.1: {} diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index cb1b5d4d2d09e..3a6a436fa93ed 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -502,6 +502,62 @@ export const SystemMessageWithoutHookNotice: Story = { }, }; +export const UserPromptWithLinks: Story = { + args: { + ...defaultArgs, + urlTransform: (url) => + url.replace("http://localhost:3000", "https://proxy.example.com"), + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { + type: "text", + text: "Please see https://coder.com/docs. Preview http://localhost:3000/app", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const docsLink = canvas.getByRole("link", { + name: "https://coder.com/docs", + }); + expect(docsLink).toHaveAttribute("href", "https://coder.com/docs"); + expect(docsLink).toHaveAttribute("target", "_blank"); + expect(docsLink).toHaveAttribute( + "rel", + expect.stringContaining("noopener"), + ); + const localhostLink = canvas.getByRole("link", { + name: "http://localhost:3000/app", + }); + expect(localhostLink).toHaveAttribute( + "href", + "https://proxy.example.com/app", + ); + expect(localhostLink).toHaveTextContent("http://localhost:3000/app"); + + let clickedHref: string | null = null; + const captureClick = (event: MouseEvent) => { + event.preventDefault(); + if (event.target instanceof HTMLAnchorElement) { + clickedHref = event.target.getAttribute("href"); + } + }; + canvasElement.addEventListener("click", captureClick, true); + try { + await userEvent.click(localhostLink); + } finally { + canvasElement.removeEventListener("click", captureClick, true); + } + expect(clickedHref).toBe("https://proxy.example.com/app"); + }, +}; + export const LifecycleHookNoticeOnUserMessage: Story = { args: { ...defaultArgs, @@ -1462,7 +1518,7 @@ export const UserMessageWithInlineFileRef: Story = { end_line: 42, content: "export const Button = ...", }, - { type: "text", text: " to use the new API?" }, + { type: "text", text: " https://coder.com/docs" }, ], }, { @@ -1482,7 +1538,9 @@ export const UserMessageWithInlineFileRef: Story = { const canvas = within(canvasElement); expect(canvas.getByText(/Button\.tsx/)).toBeInTheDocument(); expect(canvas.getByText(/Can you refactor/)).toBeInTheDocument(); - expect(canvas.getByText(/to use the new API/)).toBeInTheDocument(); + expect( + canvas.getByRole("link", { name: "https://coder.com/docs" }), + ).toHaveAttribute("href", "https://coder.com/docs"); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index 01c45d003910f..13593ad444c84 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -233,6 +233,7 @@ const ChatMessageItem = memo<{ { if (block.type === "response") { - return {block.text}; + return ( + + ); } return ( @@ -50,22 +59,27 @@ const renderUserInlineBlock = ( ); }; -const renderUserInlineContent = (blocks: readonly UserInlineRenderBlock[]) => { +const renderUserInlineContent = ( + blocks: readonly UserInlineRenderBlock[], + urlTransform?: UrlTransform, +) => { const inlineParts = getInlineParts(blocks); return blocks.map((block, index) => - renderUserInlineBlock(inlineParts, block, index), + renderUserInlineBlock(inlineParts, block, index, urlTransform), ); }; export const UserMessageContent: FC<{ displayState: MessageDisplayState; markdown: string; + urlTransform?: UrlTransform; isEditing?: boolean; onImageClick?: (src: string) => void; onTextFileClick?: (attachment: PreviewTextAttachment) => void; }> = ({ displayState, markdown, + urlTransform, isEditing = false, onImageClick, onTextFileClick, @@ -85,8 +99,16 @@ export const UserMessageContent: FC<{ {displayState.hasUserMessageBody && ( {displayState.userInlineContent.length > 0 - ? renderUserInlineContent(displayState.userInlineContent) - : markdown || ""} + ? renderUserInlineContent( + displayState.userInlineContent, + urlTransform, + ) + : markdown && ( + + )} )} diff --git a/site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx b/site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx new file mode 100644 index 0000000000000..220b44789b566 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx @@ -0,0 +1,37 @@ +import type React from "react"; +import { Fragment } from "react"; +import type { UrlTransform } from "streamdown"; +import { splitTextForLinks } from "./linkify"; + +export const LinkifiedText: React.FC<{ + text: string; + urlTransform?: UrlTransform; +}> = ({ text, urlTransform }) => { + const segments = splitTextForLinks(text); + if (!segments.some((segment) => segment.kind === "url")) { + return text; + } + return segments.map((segment, index) => { + if (segment.kind === "text") { + return {segment.value}; + } + const href = + urlTransform?.(segment.value, "href", { + type: "element", + tagName: "a", + properties: { href: segment.value }, + children: [{ type: "text", value: segment.value }], + }) ?? segment.value; + return ( + + {segment.value} + + ); + }); +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts new file mode 100644 index 0000000000000..f8404b3f6d640 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts @@ -0,0 +1,164 @@ +import { splitTextForLinks } from "./linkify"; + +describe("splitTextForLinks", () => { + it("returns a single text segment when there are no URLs", () => { + expect(splitTextForLinks("compiled 12 files in 340ms")).toEqual([ + { kind: "text", value: "compiled 12 files in 340ms" }, + ]); + }); + + it("extracts a bare URL surrounded by text", () => { + expect(splitTextForLinks("Local: http://localhost:3000/ ready")).toEqual([ + { kind: "text", value: "Local: " }, + { kind: "url", value: "http://localhost:3000/" }, + { kind: "text", value: " ready" }, + ]); + }); + + it("extracts multiple URLs and preserves whitespace between them", () => { + expect( + splitTextForLinks( + " ➜ Local: http://localhost:5173/\n ➜ Network: http://127.0.0.1:5173/\n", + ), + ).toEqual([ + { kind: "text", value: " ➜ Local: " }, + { kind: "url", value: "http://localhost:5173/" }, + { kind: "text", value: "\n ➜ Network: " }, + { kind: "url", value: "http://127.0.0.1:5173/" }, + { kind: "text", value: "\n" }, + ]); + }); + + it("keeps ports, paths, and query strings", () => { + expect( + splitTextForLinks("see https://localhost:8080/api/v2?q=1&x=2 now"), + ).toEqual([ + { kind: "text", value: "see " }, + { kind: "url", value: "https://localhost:8080/api/v2?q=1&x=2" }, + { kind: "text", value: " now" }, + ]); + }); + + it("excludes trailing sentence punctuation from the URL", () => { + expect(splitTextForLinks("Open http://localhost:3000.")).toEqual([ + { kind: "text", value: "Open " }, + { kind: "url", value: "http://localhost:3000" }, + { kind: "text", value: "." }, + ]); + expect(splitTextForLinks("Ready at http://localhost:3000!?")).toEqual([ + { kind: "text", value: "Ready at " }, + { kind: "url", value: "http://localhost:3000" }, + { kind: "text", value: "!?" }, + ]); + }); + + it("excludes a closing parenthesis that is not part of the URL", () => { + expect(splitTextForLinks("(listening on http://localhost:3000)")).toEqual([ + { kind: "text", value: "(listening on " }, + { kind: "url", value: "http://localhost:3000" }, + { kind: "text", value: ")" }, + ]); + }); + + it("keeps a balanced closing parenthesis inside the URL", () => { + expect( + splitTextForLinks("docs at https://example.com/wiki/Foo_(bar)"), + ).toEqual([ + { kind: "text", value: "docs at " }, + { kind: "url", value: "https://example.com/wiki/Foo_(bar)" }, + ]); + }); + + it("matches mixed-case schemes and preserves their text", () => { + expect( + splitTextForLinks("HTTPS://coder.com/docs and Http://localhost:3000/app"), + ).toEqual([ + { kind: "url", value: "HTTPS://coder.com/docs" }, + { kind: "text", value: " and " }, + { kind: "url", value: "Http://localhost:3000/app" }, + ]); + }); + + it("does not linkify non-http schemes", () => { + expect(splitTextForLinks("ftp://host ws://host file:///tmp/x")).toEqual([ + { kind: "text", value: "ftp://host ws://host file:///tmp/x" }, + ]); + }); + + it("does not linkify bare domains or filenames with TLD-like extensions", () => { + expect( + splitTextForLinks("please edit main.ts and README.md then run deploy.sh"), + ).toEqual([ + { + kind: "text", + value: "please edit main.ts and README.md then run deploy.sh", + }, + ]); + expect(splitTextForLinks("see github.com and www.coder.com")).toEqual([ + { kind: "text", value: "see github.com and www.coder.com" }, + ]); + }); + + it("does not linkify email addresses", () => { + expect(splitTextForLinks("contact admin@coder.com about chat.go")).toEqual([ + { kind: "text", value: "contact admin@coder.com about chat.go" }, + ]); + }); + + it("trims an unmatched closing bracket wrapping the URL", () => { + expect(splitTextForLinks("Open [http://localhost:3000] now")).toEqual([ + { kind: "text", value: "Open [" }, + { kind: "url", value: "http://localhost:3000" }, + { kind: "text", value: "] now" }, + ]); + }); + + it("trims an unmatched closing bracket after a path", () => { + expect(splitTextForLinks("[http://localhost:3000/app]")).toEqual([ + { kind: "text", value: "[" }, + { kind: "url", value: "http://localhost:3000/app" }, + { kind: "text", value: "]" }, + ]); + }); + + // Accepted linkifyjs tokenizer limitations: options can reject whole + // tokens but cannot fix their boundaries. + + it("keeps trailing Markdown emphasis delimiters in the URL", () => { + expect(splitTextForLinks("**https://coder.com/docs**")).toEqual([ + { kind: "text", value: "**" }, + { kind: "url", value: "https://coder.com/docs**" }, + ]); + expect( + splitTextForLinks("_https://coder.com/blog_ and ~https://coder.com/x~"), + ).toEqual([ + { kind: "text", value: "_" }, + { kind: "url", value: "https://coder.com/blog_" }, + { kind: "text", value: " and ~" }, + { kind: "url", value: "https://coder.com/x~" }, + ]); + }); + + it("does not detect URLs with IPv6 literal hosts", () => { + expect(splitTextForLinks("[http://[::1]:8080/]")).toEqual([ + { kind: "text", value: "[http://[::1]:8080/]" }, + ]); + }); + + it("does not linkify URLs adjacent to ANSI escape sequences", () => { + expect( + splitTextForLinks("\u001b[32mhttp://localhost:3000/\u001b[39m done"), + ).toEqual([ + { + kind: "text", + value: "\u001b[32mhttp://localhost:3000/\u001b[39m done", + }, + ]); + }); + + it("keeps ASCII control characters inside the URL", () => { + expect(splitTextForLinks("http://localhost:3000/a\u0007bell")).toEqual([ + { kind: "url", value: "http://localhost:3000/a\u0007bell" }, + ]); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts new file mode 100644 index 0000000000000..d83ddb7afb197 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts @@ -0,0 +1,31 @@ +import { find } from "linkifyjs"; + +type LinkSegment = + | { kind: "text"; value: string } + | { kind: "url"; value: string }; + +// Reject bare-domain matches: linkifyjs would otherwise linkify filenames +// like README.md, since .md is a TLD. +const options = { + validate: { url: (value: string) => /^https?:\/\//i.test(value) }, +}; + +/** Concatenating the returned segment values reproduces the input. */ +export const splitTextForLinks = (text: string): LinkSegment[] => { + const segments: LinkSegment[] = []; + let lastIndex = 0; + for (const link of find(text, "url", options)) { + if (link.start > lastIndex) { + segments.push({ + kind: "text", + value: text.slice(lastIndex, link.start), + }); + } + segments.push({ kind: "url", value: link.value }); + lastIndex = link.end; + } + if (lastIndex < text.length) { + segments.push({ kind: "text", value: text.slice(lastIndex) }); + } + return segments; +};