From a83830437c2c9ea92d5322187dd30731574a576a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:10:25 +0000 Subject: [PATCH 1/6] fix(site/src/pages/AgentsPage): linkify prompt URLs --- .../ConversationTimeline.stories.tsx | 47 ++++++- .../ChatConversation/ConversationTimeline.tsx | 1 + .../ChatConversation/UserMessageContent.tsx | 30 ++++- .../components/ChatElements/LinkifiedText.tsx | 37 ++++++ .../components/ChatElements/linkify.test.ts | 118 ++++++++++++++++++ .../components/ChatElements/linkify.ts | 71 +++++++++++ 6 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx create mode 100644 site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts create mode 100644 site/src/pages/AgentsPage/components/ChatElements/linkify.ts diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index cb1b5d4d2d0..310ab756368 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -502,6 +502,47 @@ 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"); + }, +}; + export const LifecycleHookNoticeOnUserMessage: Story = { args: { ...defaultArgs, @@ -1462,7 +1503,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 +1523,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 01c45d00391..13593ad444c 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 +57,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 +97,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 00000000000..b7be6cb5b9d --- /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; + transform?: UrlTransform; +}> = ({ text, transform }) => { + 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 = + transform?.(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 00000000000..3d157d9a791 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts @@ -0,0 +1,118 @@ +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("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("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: "]" }, + ]); + }); + + it("keeps IPv6 host brackets while trimming a wrapper bracket", () => { + expect(splitTextForLinks("[http://[::1]:8080/]")).toEqual([ + { kind: "text", value: "[" }, + { kind: "url", value: "http://[::1]:8080/" }, + { kind: "text", value: "]" }, + ]); + }); + + it("stops the URL at ANSI escape sequences", () => { + expect( + splitTextForLinks("\u001b[32mhttp://localhost:3000/\u001b[39m done"), + ).toEqual([ + { kind: "text", value: "\u001b[32m" }, + { kind: "url", value: "http://localhost:3000/" }, + { kind: "text", value: "\u001b[39m done" }, + ]); + }); + + it("stops the URL at other ASCII control characters", () => { + expect(splitTextForLinks("http://localhost:3000/a\u0007bell")).toEqual([ + { kind: "url", value: "http://localhost:3000/a" }, + { kind: "text", value: "\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 00000000000..608fbb63e1f --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts @@ -0,0 +1,71 @@ +type LinkSegment = + | { kind: "text"; value: string } + | { kind: "url"; value: string }; + +// Control characters must end URLs so ANSI escapes cannot become part of them. +// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional +const URL_PATTERN = /https?:\/\/[^\s<>"'`\u0000-\u001f\u007f]+/g; + +// Characters that end a sentence around a URL far more often than +// they end the URL itself. +const TRAILING_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?"]); + +const trimTrailingPunctuation = (url: string): string => { + let parenBalance = 0; + let bracketBalance = 0; + for (const char of url) { + if (char === "(") { + parenBalance += 1; + } else if (char === ")") { + parenBalance -= 1; + } else if (char === "[") { + bracketBalance += 1; + } else if (char === "]") { + bracketBalance -= 1; + } + } + let end = url.length; + while (end > 0) { + const char = url[end - 1]; + if (TRAILING_PUNCTUATION.has(char)) { + end -= 1; + continue; + } + // Preserve balanced URL parentheses while trimming unmatched closers. + if (char === ")" && parenBalance < 0) { + parenBalance += 1; + end -= 1; + continue; + } + // Same for brackets, so "[http://x]" sheds its wrapper while IPv6 + // hosts like http://[::1]:8080/ keep theirs. + if (char === "]" && bracketBalance < 0) { + bracketBalance += 1; + end -= 1; + continue; + } + break; + } + return url.slice(0, end); +}; + +/** Concatenating the returned segment values reproduces the input. */ +export const splitTextForLinks = (text: string): LinkSegment[] => { + const segments: LinkSegment[] = []; + let lastIndex = 0; + for (const match of text.matchAll(URL_PATTERN)) { + const url = trimTrailingPunctuation(match[0]); + if (match.index > lastIndex) { + segments.push({ + kind: "text", + value: text.slice(lastIndex, match.index), + }); + } + segments.push({ kind: "url", value: url }); + lastIndex = match.index + url.length; + } + if (lastIndex < text.length) { + segments.push({ kind: "text", value: text.slice(lastIndex) }); + } + return segments; +}; From 87ed3260a8319add6bb01d2445bbe7feea7ffb1f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:58:59 +0000 Subject: [PATCH 2/6] refactor(site/src/pages/AgentsPage): drop redundant Fragment and align urlTransform prop name --- .../ChatConversation/UserMessageContent.tsx | 12 +++++++----- .../components/ChatElements/LinkifiedText.tsx | 6 +++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx index cf288df6be7..fa2ad9e9c27 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx @@ -1,4 +1,4 @@ -import { type FC, Fragment } from "react"; +import type { FC } from "react"; import type { UrlTransform } from "streamdown"; import { cn } from "#/utils/cn"; import { Message, MessageContent } from "../ChatElements"; @@ -37,9 +37,11 @@ const renderUserInlineBlock = ( ) => { if (block.type === "response") { return ( - - - + ); } @@ -104,7 +106,7 @@ export const UserMessageContent: FC<{ : markdown && ( )} diff --git a/site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx b/site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx index b7be6cb5b9d..220b44789b5 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/LinkifiedText.tsx @@ -5,8 +5,8 @@ import { splitTextForLinks } from "./linkify"; export const LinkifiedText: React.FC<{ text: string; - transform?: UrlTransform; -}> = ({ text, transform }) => { + urlTransform?: UrlTransform; +}> = ({ text, urlTransform }) => { const segments = splitTextForLinks(text); if (!segments.some((segment) => segment.kind === "url")) { return text; @@ -16,7 +16,7 @@ export const LinkifiedText: React.FC<{ return {segment.value}; } const href = - transform?.(segment.value, "href", { + urlTransform?.(segment.value, "href", { type: "element", tagName: "a", properties: { href: segment.value }, From cde0f9a554c6a34ba77d27505627a300fd20c16f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:11:40 +0000 Subject: [PATCH 3/6] fix(site/src/pages/AgentsPage): trim trailing Markdown emphasis from prompt links and click-test activation --- .../ConversationTimeline.stories.tsx | 17 +++++++++++++++++ .../components/ChatElements/linkify.test.ts | 17 +++++++++++++++++ .../components/ChatElements/linkify.ts | 16 +++++++++++++--- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 310ab756368..ce6407920ac 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -540,6 +540,23 @@ export const UserPromptWithLinks: Story = { "https://proxy.example.com/app", ); expect(localhostLink).toHaveTextContent("http://localhost:3000/app"); + + // Activate the link for real (intercepting navigation) to prove + // surrounding handlers do not swallow the click. + 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"); }, }; diff --git a/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts index 3d157d9a791..9ea47428795 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts @@ -52,6 +52,23 @@ describe("splitTextForLinks", () => { ]); }); + it("excludes trailing Markdown emphasis delimiters from the URL", () => { + expect(splitTextForLinks("**https://coder.com/docs**")).toEqual([ + { kind: "text", value: "**" }, + { kind: "url", value: "https://coder.com/docs" }, + { kind: "text", value: "**" }, + ]); + 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" }, + { 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 " }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts index 608fbb63e1f..3aba0ac20ec 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts @@ -6,9 +6,19 @@ type LinkSegment = // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional const URL_PATTERN = /https?:\/\/[^\s<>"'`\u0000-\u001f\u007f]+/g; -// Characters that end a sentence around a URL far more often than -// they end the URL itself. -const TRAILING_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?"]); +// Trailing characters GFM's autolinker excludes from bare URLs: +// sentence punctuation and Markdown emphasis delimiters. +const TRAILING_PUNCTUATION = new Set([ + ".", + ",", + ";", + ":", + "!", + "?", + "*", + "_", + "~", +]); const trimTrailingPunctuation = (url: string): string => { let parenBalance = 0; From 498b1d9864994393c22cd97a6f294f4c09ecc7e6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:18:23 +0000 Subject: [PATCH 4/6] chore(site/src/pages/AgentsPage): comment cleanup --- .../ChatConversation/ConversationTimeline.stories.tsx | 2 -- site/src/pages/AgentsPage/components/ChatElements/linkify.ts | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index ce6407920ac..3a6a436fa93 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -541,8 +541,6 @@ export const UserPromptWithLinks: Story = { ); expect(localhostLink).toHaveTextContent("http://localhost:3000/app"); - // Activate the link for real (intercepting navigation) to prove - // surrounding handlers do not swallow the click. let clickedHref: string | null = null; const captureClick = (event: MouseEvent) => { event.preventDefault(); diff --git a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts index 3aba0ac20ec..129fee1294f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts @@ -6,8 +6,7 @@ type LinkSegment = // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional const URL_PATTERN = /https?:\/\/[^\s<>"'`\u0000-\u001f\u007f]+/g; -// Trailing characters GFM's autolinker excludes from bare URLs: -// sentence punctuation and Markdown emphasis delimiters. +// GFM autolinks treat these characters as trailing punctuation. const TRAILING_PUNCTUATION = new Set([ ".", ",", From 5b8131ba39529845e31c9701506ed70b88b4cbe4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:29:24 +0000 Subject: [PATCH 5/6] fix(site/src/pages/AgentsPage): match prompt URL schemes case-insensitively --- .../AgentsPage/components/ChatElements/linkify.test.ts | 10 ++++++++++ .../AgentsPage/components/ChatElements/linkify.ts | 3 ++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts index 9ea47428795..7b0d5747680 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts @@ -86,6 +86,16 @@ describe("splitTextForLinks", () => { ]); }); + 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" }, diff --git a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts index 129fee1294f..d53117e61ba 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts @@ -3,8 +3,9 @@ type LinkSegment = | { kind: "url"; value: string }; // Control characters must end URLs so ANSI escapes cannot become part of them. +// Schemes match case-insensitively, like GFM's autolinker. // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional -const URL_PATTERN = /https?:\/\/[^\s<>"'`\u0000-\u001f\u007f]+/g; +const URL_PATTERN = /https?:\/\/[^\s<>"'`\u0000-\u001f\u007f]+/gi; // GFM autolinks treat these characters as trailing punctuation. const TRAILING_PUNCTUATION = new Set([ From 827410961fcd7eb088f7117f7de49f3056259f39 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:15:15 +0000 Subject: [PATCH 6/6] refactor(site): replace hand-rolled prompt linkifier with linkifyjs --- site/package.json | 1 + site/pnpm-lock.yaml | 8 ++ .../components/ChatElements/linkify.test.ts | 75 ++++++++++++------- .../components/ChatElements/linkify.ts | 72 +++--------------- 4 files changed, 67 insertions(+), 89 deletions(-) diff --git a/site/package.json b/site/package.json index 7c256e374cd..dd0d022709f 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 36e1eb8bcb8..1ca58e15348 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/ChatElements/linkify.test.ts b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts index 7b0d5747680..f8404b3f6d6 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.test.ts @@ -52,23 +52,6 @@ describe("splitTextForLinks", () => { ]); }); - it("excludes trailing Markdown emphasis delimiters from the URL", () => { - expect(splitTextForLinks("**https://coder.com/docs**")).toEqual([ - { kind: "text", value: "**" }, - { kind: "url", value: "https://coder.com/docs" }, - { kind: "text", value: "**" }, - ]); - 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" }, - { 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 " }, @@ -102,6 +85,26 @@ describe("splitTextForLinks", () => { ]); }); + 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 [" }, @@ -118,28 +121,44 @@ describe("splitTextForLinks", () => { ]); }); - it("keeps IPv6 host brackets while trimming a wrapper bracket", () => { + // 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: "[" }, - { kind: "url", value: "http://[::1]:8080/" }, - { kind: "text", value: "]" }, + { kind: "text", value: "[http://[::1]:8080/]" }, ]); }); - it("stops the URL at ANSI escape sequences", () => { + it("does not linkify URLs adjacent to ANSI escape sequences", () => { expect( splitTextForLinks("\u001b[32mhttp://localhost:3000/\u001b[39m done"), ).toEqual([ - { kind: "text", value: "\u001b[32m" }, - { kind: "url", value: "http://localhost:3000/" }, - { kind: "text", value: "\u001b[39m done" }, + { + kind: "text", + value: "\u001b[32mhttp://localhost:3000/\u001b[39m done", + }, ]); }); - it("stops the URL at other ASCII control characters", () => { + it("keeps ASCII control characters inside the URL", () => { expect(splitTextForLinks("http://localhost:3000/a\u0007bell")).toEqual([ - { kind: "url", value: "http://localhost:3000/a" }, - { kind: "text", value: "\u0007bell" }, + { 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 index d53117e61ba..d83ddb7afb1 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/linkify.ts +++ b/site/src/pages/AgentsPage/components/ChatElements/linkify.ts @@ -1,78 +1,28 @@ +import { find } from "linkifyjs"; + type LinkSegment = | { kind: "text"; value: string } | { kind: "url"; value: string }; -// Control characters must end URLs so ANSI escapes cannot become part of them. -// Schemes match case-insensitively, like GFM's autolinker. -// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional -const URL_PATTERN = /https?:\/\/[^\s<>"'`\u0000-\u001f\u007f]+/gi; - -// GFM autolinks treat these characters as trailing punctuation. -const TRAILING_PUNCTUATION = new Set([ - ".", - ",", - ";", - ":", - "!", - "?", - "*", - "_", - "~", -]); - -const trimTrailingPunctuation = (url: string): string => { - let parenBalance = 0; - let bracketBalance = 0; - for (const char of url) { - if (char === "(") { - parenBalance += 1; - } else if (char === ")") { - parenBalance -= 1; - } else if (char === "[") { - bracketBalance += 1; - } else if (char === "]") { - bracketBalance -= 1; - } - } - let end = url.length; - while (end > 0) { - const char = url[end - 1]; - if (TRAILING_PUNCTUATION.has(char)) { - end -= 1; - continue; - } - // Preserve balanced URL parentheses while trimming unmatched closers. - if (char === ")" && parenBalance < 0) { - parenBalance += 1; - end -= 1; - continue; - } - // Same for brackets, so "[http://x]" sheds its wrapper while IPv6 - // hosts like http://[::1]:8080/ keep theirs. - if (char === "]" && bracketBalance < 0) { - bracketBalance += 1; - end -= 1; - continue; - } - break; - } - return url.slice(0, end); +// 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 match of text.matchAll(URL_PATTERN)) { - const url = trimTrailingPunctuation(match[0]); - if (match.index > lastIndex) { + for (const link of find(text, "url", options)) { + if (link.start > lastIndex) { segments.push({ kind: "text", - value: text.slice(lastIndex, match.index), + value: text.slice(lastIndex, link.start), }); } - segments.push({ kind: "url", value: url }); - lastIndex = match.index + url.length; + segments.push({ kind: "url", value: link.value }); + lastIndex = link.end; } if (lastIndex < text.length) { segments.push({ kind: "text", value: text.slice(lastIndex) });