Skip to content

Commit b4aec03

Browse files
committed
feat: clickable links in agentapi ui
1 parent d0b5ad0 commit b4aec03

File tree

1 file changed

+62
-3
lines changed

1 file changed

+62
-3
lines changed

chat/src/components/message-list.tsx

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useLayoutEffect, useRef, useEffect, useCallback } from "react";
3+
import React, {useLayoutEffect, useRef, useEffect, useCallback, useMemo, useState} from "react";
44

55
interface Message {
66
role: string;
@@ -38,6 +38,31 @@ export default function MessageList({ messages }: MessageListProps) {
3838
return scrollTop + clientHeight >= scrollHeight - 10; // 10px tolerance
3939
}, []);
4040

41+
// Regex to find URLs
42+
// https://stackoverflow.com/a/17773849
43+
const urlRegex = useMemo<RegExp>(() => /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g, []);
44+
45+
const [modifierPressed, setModifierPressed] = useState(false);
46+
47+
// Track Ctrl (Windows/Linux) or Cmd (Mac) key state
48+
// This is so that underline is only visible when hover + cmd/ctrl
49+
useEffect(() => {
50+
const handleKeyDown = (e: KeyboardEvent) => {
51+
if (e.ctrlKey || e.metaKey) setModifierPressed(true);
52+
};
53+
const handleKeyUp = (e: KeyboardEvent) => {
54+
if (!e.ctrlKey && !e.metaKey) setModifierPressed(false);
55+
};
56+
57+
window.addEventListener("keydown", handleKeyDown);
58+
window.addEventListener("keyup", handleKeyUp);
59+
60+
return () => {
61+
window.removeEventListener("keydown", handleKeyDown);
62+
window.removeEventListener("keyup", handleKeyUp);
63+
};
64+
}, []);
65+
4166
// Update isAtBottom on scroll
4267
useEffect(() => {
4368
const scrollContainer = scrollAreaRef.current;
@@ -92,6 +117,40 @@ export default function MessageList({ messages }: MessageListProps) {
92117
lastScrollHeightRef.current = currentScrollHeight;
93118
}, [messages]);
94119

120+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, url: string) => {
121+
if (e.metaKey || e.ctrlKey) {
122+
window.open(url, "_blank");
123+
} else {
124+
e.preventDefault(); // disable normal click to emulate terminal behaviour
125+
}
126+
};
127+
128+
const buildClickableLinks = useCallback((message: string, msg_index: number) => {
129+
const linkedContent = message.split(urlRegex).map((content, index) => {
130+
if (urlRegex.test(content)) {
131+
return (
132+
<a
133+
key={`${msg_index}-${index}`}
134+
href={content}
135+
onClick={(e) => handleClick(e, content)}
136+
className={`${
137+
modifierPressed ? "hover:underline cursor-pointer" : "cursor-default"
138+
}`}
139+
>
140+
{content}
141+
</a>
142+
);
143+
}
144+
return <span key={`${msg_index}-${index}`}>{content}</span>;
145+
})
146+
147+
return <>
148+
{linkedContent}
149+
</>
150+
}, [modifierPressed, urlRegex])
151+
152+
153+
95154
// If no messages, show a placeholder
96155
if (messages.length === 0) {
97156
return (
@@ -107,7 +166,7 @@ export default function MessageList({ messages }: MessageListProps) {
107166
className="p-4 flex flex-col gap-4 max-w-4xl mx-auto"
108167
style={{ minHeight: contentMinHeight.current }}
109168
>
110-
{messages.map((message) => (
169+
{messages.map((message, index) => (
111170
<div
112171
key={message.id ?? "draft"}
113172
className={`${message.role === "user" ? "text-right" : ""}`}
@@ -127,7 +186,7 @@ export default function MessageList({ messages }: MessageListProps) {
127186
{message.role !== "user" && message.content === "" ? (
128187
<LoadingDots />
129188
) : (
130-
message.content.trimEnd()
189+
buildClickableLinks(message.content.trimEnd(), index)
131190
)}
132191
</div>
133192
</div>

0 commit comments

Comments
 (0)