diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx index 4c2f7072722..08cd95a8738 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.stories.tsx @@ -1062,6 +1062,79 @@ export const UserMessageWithMixedAttachments: Story = { }, }; +export const UserMessageLinksStayLiteralAndSafe: Story = { + args: { + ...buildStoryArgs( + buildUserMessage({ + text: "Visit https://coder.com, www.coder.com, see https://coder.com/docs. Literal [docs](https://coder.com/docs) ", + }), + ), + urlTransform: fn((url) => + url === "https://coder.com" ? "https://proxy.example" : url, + ), + }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + expect(args.urlTransform).toHaveBeenCalled(); + const secureLink = canvas.getByRole("link", { name: "https://coder.com" }); + expect(secureLink).toHaveAttribute("href", "https://proxy.example"); + expect(secureLink).toHaveAttribute("target", "_blank"); + expect(secureLink).toHaveAttribute( + "rel", + expect.stringContaining("noopener"), + ); + expect(secureLink).toHaveAttribute( + "rel", + expect.stringContaining("noreferrer"), + ); + + expect(canvas.getByRole("link", { name: "www.coder.com" })).toHaveAttribute( + "href", + "https://www.coder.com", + ); + const docsLinks = canvas.getAllByRole("link", { + name: "https://coder.com/docs", + }); + expect(docsLinks).toHaveLength(2); + for (const link of docsLinks) { + expect(link).toHaveAttribute("href", "https://coder.com/docs"); + } + + const userRow = secureLink.closest('[data-role="user"]'); + expect(userRow).not.toBeNull(); + expect(userRow).toHaveTextContent("see https://coder.com/docs."); + expect(userRow).toHaveTextContent("[docs](https://coder.com/docs)"); + expect(userRow).toHaveTextContent(""); + expect(userRow?.querySelector("script")).toBeNull(); + }, +}; + +export const AssistantMessageBareURLIsLinked: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "assistant", + content: [ + { + type: "text", + text: "Read more at https://coder.com/docs", + }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const link = canvas.getByRole("link", { name: "https://coder.com/docs" }); + expect(link).toHaveAttribute("href", "https://coder.com/docs"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", expect.stringContaining("noopener")); + }, +}; + /** Text-only messages must not produce spurious image thumbnails. */ export const UserMessageTextOnly: Story = { args: { @@ -1200,6 +1273,38 @@ export const UserMessageWithImagesAndFileRefs: Story = { }, }; +export const UserMessageLinkWithInlineFileRef: Story = { + args: { + ...defaultArgs, + parsedMessages: buildMessages([ + { + ...baseMessage, + id: 1, + role: "user", + content: [ + { type: "text", text: "Review https://coder.com/docs with " }, + { + type: "file-reference", + file_name: "site/src/components/Button.tsx", + start_line: 42, + end_line: 42, + content: "export const Button = ...", + }, + { type: "text", text: " before shipping." }, + ], + }, + ]), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("link", { name: "https://coder.com/docs" }), + ).toHaveAttribute("href", "https://coder.com/docs"); + expect(canvas.getByText(/Button\.tsx/)).toBeInTheDocument(); + expect(canvas.getByText(/before shipping/)).toBeInTheDocument(); + }, +}; + /** File references render inline with text, matching the chat input style. */ export const UserMessageWithInlineFileRef: Story = { args: { diff --git a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx index fa74ec3b7e7..526049ef5d8 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/ConversationTimeline.tsx @@ -610,6 +610,7 @@ const ChatMessageItem = memo<{ markdown={parsed.markdown} isEditing={editingMessageId === message.id} fadeFromBottom={fadeFromBottom} + urlTransform={urlTransform} onImageClick={setPreviewImage} onTextFileClick={setPreviewText} /> @@ -778,6 +779,7 @@ const StickyUserMessage = memo<{ prevUserMessageId?: number; nextUserMessageId?: number; onJumpToUserMessage?: (messageId: number) => void; + urlTransform?: UrlTransform; registerSentinel?: (messageId: number, el: HTMLDivElement | null) => void; }>( ({ @@ -789,6 +791,7 @@ const StickyUserMessage = memo<{ prevUserMessageId, nextUserMessageId, onJumpToUserMessage, + urlTransform, registerSentinel, }) => { const [isStuck, setIsStuck] = useState(false); @@ -1028,6 +1031,7 @@ const StickyUserMessage = memo<{ prevUserMessageId={prevUserMessageId} nextUserMessageId={nextUserMessageId} onJumpToUserMessage={onJumpToUserMessage} + urlTransform={urlTransform} /> @@ -1073,6 +1077,7 @@ const StickyUserMessage = memo<{ prevUserMessageId={prevUserMessageId} nextUserMessageId={nextUserMessageId} onJumpToUserMessage={onJumpToUserMessage} + urlTransform={urlTransform} fadeFromBottom /> @@ -1266,6 +1271,7 @@ export const ConversationTimeline = memo( prevUserMessageId={userNeighborsById.get(message.id)?.prevId} nextUserMessageId={userNeighborsById.get(message.id)?.nextId} onJumpToUserMessage={jumpToUserMessage} + urlTransform={urlTransform} registerSentinel={registerSentinel} /> ); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LinkifiedText.test.ts b/site/src/pages/AgentsPage/components/ChatConversation/LinkifiedText.test.ts new file mode 100644 index 00000000000..7324ec25c91 --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/LinkifiedText.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { tokenizeLinkifiedText } from "./LinkifiedText"; + +describe("tokenizeLinkifiedText", () => { + it.each([ + { + name: "links an HTTPS URL in a sentence", + text: "Visit https://coder.com/docs for details", + expected: [ + { type: "text", value: "Visit " }, + { + type: "url", + value: "https://coder.com/docs", + href: "https://coder.com/docs", + }, + { type: "text", value: " for details" }, + ], + }, + { + name: "links an HTTP URL", + text: "http://localhost:3000/path", + expected: [ + { + type: "url", + value: "http://localhost:3000/path", + href: "http://localhost:3000/path", + }, + ], + }, + { + name: "adds an HTTPS scheme to a www URL", + text: "www.coder.com", + expected: [ + { + type: "url", + value: "www.coder.com", + href: "https://www.coder.com", + }, + ], + }, + { + name: "excludes trailing punctuation", + text: "See https://coder.com/docs., and www.coder.com?", + expected: [ + { type: "text", value: "See " }, + { + type: "url", + value: "https://coder.com/docs", + href: "https://coder.com/docs", + }, + { type: "text", value: "., and " }, + { + type: "url", + value: "www.coder.com", + href: "https://www.coder.com", + }, + { type: "text", value: "?" }, + ], + }, + { + name: "keeps balanced URL parentheses", + text: "https://en.wikipedia.org/wiki/Foo_(bar)", + expected: [ + { + type: "url", + value: "https://en.wikipedia.org/wiki/Foo_(bar)", + href: "https://en.wikipedia.org/wiki/Foo_(bar)", + }, + ], + }, + { + name: "excludes an unmatched trailing parenthesis", + text: "(https://coder.com/docs)", + expected: [ + { type: "text", value: "(" }, + { + type: "url", + value: "https://coder.com/docs", + href: "https://coder.com/docs", + }, + { type: "text", value: ")" }, + ], + }, + { + name: "links multiple URLs", + text: "https://coder.com and www.example.com", + expected: [ + { + type: "url", + value: "https://coder.com", + href: "https://coder.com", + }, + { type: "text", value: " and " }, + { + type: "url", + value: "www.example.com", + href: "https://www.example.com", + }, + ], + }, + { + name: "preserves text without URLs", + text: "Keep *markdown* literal", + expected: [{ type: "text", value: "Keep *markdown* literal" }], + }, + { + name: "preserves newlines in text segments", + text: "First line\nhttps://coder.com\nLast line", + expected: [ + { type: "text", value: "First line\n" }, + { + type: "url", + value: "https://coder.com", + href: "https://coder.com", + }, + { type: "text", value: "\nLast line" }, + ], + }, + { + name: "does not link a bare domain", + text: "Visit coder.com", + expected: [{ type: "text", value: "Visit coder.com" }], + }, + { + name: "does not link incomplete URL prefixes", + text: "Keep https:// and www. literal", + expected: [{ type: "text", value: "Keep https:// and www. literal" }], + }, + { + name: "handles empty text", + text: "", + expected: [], + }, + ])("$name", ({ text, expected }) => { + expect(tokenizeLinkifiedText(text)).toEqual(expected); + }); +}); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/LinkifiedText.tsx b/site/src/pages/AgentsPage/components/ChatConversation/LinkifiedText.tsx new file mode 100644 index 00000000000..90e8dcc6dfa --- /dev/null +++ b/site/src/pages/AgentsPage/components/ChatConversation/LinkifiedText.tsx @@ -0,0 +1,102 @@ +import { type FC, Fragment } from "react"; +import type { UrlTransform } from "streamdown"; + +const URL_PATTERN = + /\b(?:https?:\/\/(?:[a-z0-9]|\[[0-9a-f:]+\])|www\.[a-z0-9])[^\s<>"']*/gi; +const TRAILING_PUNCTUATION = new Set([".", ",", ";", ":", "!", "?"]); + +type LinkifiedTextSegment = + | { type: "text"; value: string } + | { type: "url"; value: string; href: string }; + +const trimURLSuffix = (value: string) => { + let end = value.length; + let openParentheses = 0; + let closeParentheses = 0; + + for (const character of value) { + if (character === "(") { + openParentheses++; + } else if (character === ")") { + closeParentheses++; + } + } + + while (end > 0) { + const character = value[end - 1]; + if (TRAILING_PUNCTUATION.has(character)) { + end--; + continue; + } + if (character === ")" && closeParentheses > openParentheses) { + end--; + closeParentheses--; + continue; + } + break; + } + + return value.slice(0, end); +}; + +/** Splits literal prompt text into text and safe HTTP(S) URL segments. */ +export const tokenizeLinkifiedText = (text: string): LinkifiedTextSegment[] => { + const segments: LinkifiedTextSegment[] = []; + let cursor = 0; + + for (const match of text.matchAll(URL_PATTERN)) { + const index = match.index; + const value = trimURLSuffix(match[0]); + if (value.length === 0) { + continue; + } + + if (index > cursor) { + segments.push({ type: "text", value: text.slice(cursor, index) }); + } + segments.push({ + type: "url", + value, + href: value.toLowerCase().startsWith("www.") ? `https://${value}` : value, + }); + cursor = index + value.length; + } + + if (cursor < text.length) { + segments.push({ type: "text", value: text.slice(cursor) }); + } + + return segments; +}; + +export const LinkifiedText: FC<{ + text: string; + urlTransform?: UrlTransform; +}> = ({ text, urlTransform }) => { + return tokenizeLinkifiedText(text).map((segment, index) => { + if (segment.type === "text") { + return {segment.value}; + } + + const href = + (urlTransform + ? urlTransform(segment.href, "href", { + type: "element", + tagName: "a", + properties: { href: segment.href }, + children: [{ type: "text", value: segment.value }], + }) + : segment.href) ?? segment.href; + return ( + + {segment.value} + + ); + }); +}; diff --git a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx index 7443d88b41a..98528ca9f7d 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx +++ b/site/src/pages/AgentsPage/components/ChatConversation/UserMessageContent.tsx @@ -1,4 +1,5 @@ -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"; import { FileReferenceChip } from "../ChatMessageInput/FileReferenceChip"; @@ -11,6 +12,7 @@ import { AttachmentBlock, type PreviewTextAttachment, } from "./AttachmentBlocks"; +import { LinkifiedText } from "./LinkifiedText"; import type { MessageDisplayState, UserInlineRenderBlock, @@ -31,9 +33,16 @@ const renderUserInlineBlock = ( inlineParts: readonly InlinePart[], block: UserInlineRenderBlock, index: number, + urlTransform?: UrlTransform, ) => { if (block.type === "response") { - return {block.text}; + return ( + + ); } return ( @@ -50,10 +59,13 @@ 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), ); }; @@ -62,6 +74,7 @@ export const UserMessageContent: FC<{ markdown: string; isEditing?: boolean; fadeFromBottom?: boolean; + urlTransform?: UrlTransform; onImageClick?: (src: string) => void; onTextFileClick?: (attachment: PreviewTextAttachment) => void; }> = ({ @@ -69,6 +82,7 @@ export const UserMessageContent: FC<{ markdown, isEditing = false, fadeFromBottom = false, + urlTransform, onImageClick, onTextFileClick, }) => { @@ -90,9 +104,17 @@ export const UserMessageContent: FC<{
{displayState.hasUserMessageBody && ( - {displayState.userInlineContent.length > 0 - ? renderUserInlineContent(displayState.userInlineContent) - : markdown || ""} + {displayState.userInlineContent.length > 0 ? ( + renderUserInlineContent( + displayState.userInlineContent, + urlTransform, + ) + ) : ( + + )} )}