(
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 0000000000000..7324ec25c9188
--- /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 0000000000000..90e8dcc6dfa99
--- /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 7443d88b41acb..98528ca9f7dda 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,
+ )
+ ) : (
+
+ )}
)}