From b4aec03f2dccaf5000bb1dec659106fcf4c19f05 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Thu, 25 Sep 2025 22:26:05 +0530 Subject: [PATCH 1/4] feat: clickable links in agentapi ui --- chat/src/components/message-list.tsx | 65 ++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index d675a85..a3adb6c 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useLayoutEffect, useRef, useEffect, useCallback } from "react"; +import React, {useLayoutEffect, useRef, useEffect, useCallback, useMemo, useState} from "react"; interface Message { role: string; @@ -38,6 +38,31 @@ export default function MessageList({ messages }: MessageListProps) { return scrollTop + clientHeight >= scrollHeight - 10; // 10px tolerance }, []); + // Regex to find URLs + // https://stackoverflow.com/a/17773849 + const urlRegex = useMemo(() => /(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, []); + + const [modifierPressed, setModifierPressed] = useState(false); + + // Track Ctrl (Windows/Linux) or Cmd (Mac) key state + // This is so that underline is only visible when hover + cmd/ctrl + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey) setModifierPressed(true); + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (!e.ctrlKey && !e.metaKey) setModifierPressed(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, []); + // Update isAtBottom on scroll useEffect(() => { const scrollContainer = scrollAreaRef.current; @@ -92,6 +117,40 @@ export default function MessageList({ messages }: MessageListProps) { lastScrollHeightRef.current = currentScrollHeight; }, [messages]); + const handleClick = (e: React.MouseEvent, url: string) => { + if (e.metaKey || e.ctrlKey) { + window.open(url, "_blank"); + } else { + e.preventDefault(); // disable normal click to emulate terminal behaviour + } + }; + + const buildClickableLinks = useCallback((message: string, msg_index: number) => { + const linkedContent = message.split(urlRegex).map((content, index) => { + if (urlRegex.test(content)) { + return ( + handleClick(e, content)} + className={`${ + modifierPressed ? "hover:underline cursor-pointer" : "cursor-default" + }`} + > + {content} + + ); + } + return {content}; + }) + + return <> + {linkedContent} + + }, [modifierPressed, urlRegex]) + + + // If no messages, show a placeholder if (messages.length === 0) { return ( @@ -107,7 +166,7 @@ export default function MessageList({ messages }: MessageListProps) { className="p-4 flex flex-col gap-4 max-w-4xl mx-auto" style={{ minHeight: contentMinHeight.current }} > - {messages.map((message) => ( + {messages.map((message, index) => (
) : ( - message.content.trimEnd() + buildClickableLinks(message.content.trimEnd(), index) )}
From c15436c7e9012e60c59938cfbec0e23fe431d055 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 28 Sep 2025 17:23:59 +0530 Subject: [PATCH 2/4] feat: add memoized component ProcessedMessage --- chat/src/components/message-list.tsx | 85 +++++++++++++++++----------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index a3adb6c..8db1a5a 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -18,7 +18,15 @@ interface MessageListProps { messages: (Message | DraftMessage)[]; } -export default function MessageList({ messages }: MessageListProps) { +interface ProcessedMessageProps { + messageContent: string; + index: number; + modifierPressed: boolean; + urlRegex: RegExp; + handleClick: (e: React.MouseEvent, url: string) => void; +} + +export default function MessageList({messages}: MessageListProps) { const scrollAreaRef = useRef(null); // Avoid the message list to change its height all the time. It causes some // flickering in the screen because some messages, as the ones displaying @@ -34,7 +42,7 @@ export default function MessageList({ messages }: MessageListProps) { const checkIfAtBottom = useCallback(() => { if (!scrollAreaRef.current) return false; - const { scrollTop, scrollHeight, clientHeight } = scrollAreaRef.current; + const {scrollTop, scrollHeight, clientHeight} = scrollAreaRef.current; return scrollTop + clientHeight >= scrollHeight - 10; // 10px tolerance }, []); @@ -117,39 +125,13 @@ export default function MessageList({ messages }: MessageListProps) { lastScrollHeightRef.current = currentScrollHeight; }, [messages]); - const handleClick = (e: React.MouseEvent, url: string) => { + const handleClick = useCallback(() => (e: React.MouseEvent, url: string) => { if (e.metaKey || e.ctrlKey) { window.open(url, "_blank"); } else { e.preventDefault(); // disable normal click to emulate terminal behaviour } - }; - - const buildClickableLinks = useCallback((message: string, msg_index: number) => { - const linkedContent = message.split(urlRegex).map((content, index) => { - if (urlRegex.test(content)) { - return ( - handleClick(e, content)} - className={`${ - modifierPressed ? "hover:underline cursor-pointer" : "cursor-default" - }`} - > - {content} - - ); - } - return {content}; - }) - - return <> - {linkedContent} - - }, [modifierPressed, urlRegex]) - - + }, []); // If no messages, show a placeholder if (messages.length === 0) { @@ -164,7 +146,7 @@ export default function MessageList({ messages }: MessageListProps) {
{messages.map((message, index) => (
{message.role !== "user" && message.content === "" ? ( - + ) : ( - buildClickableLinks(message.content.trimEnd(), index) + )}
@@ -214,3 +202,34 @@ const LoadingDots = () => ( Loading...
); + + +const ProcessedMessage = React.memo(function ProcessedMessage({ + messageContent, + index, + modifierPressed, + urlRegex, + handleClick + }: ProcessedMessageProps) { + const linkedContent = useMemo(() => { + return messageContent.split(urlRegex).map((content, idx) => { + if (urlRegex.test(content)) { + return ( + handleClick(e, content)} + className={`${ + modifierPressed ? "hover:underline cursor-pointer" : "cursor-default" + }`} + > + {content} + + ); + } + return {content}; + }); + }, [handleClick, index, messageContent, modifierPressed, urlRegex]); + + return <>{linkedContent}; +}); \ No newline at end of file From c736ef97a77ccd70a270d9c153da67e06e53469e Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 30 Sep 2025 21:49:16 +0530 Subject: [PATCH 3/4] chore: minor fixes --- chat/src/components/message-list.tsx | 48 +++++++++++----------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index 8db1a5a..3b94952 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -21,9 +21,6 @@ interface MessageListProps { interface ProcessedMessageProps { messageContent: string; index: number; - modifierPressed: boolean; - urlRegex: RegExp; - handleClick: (e: React.MouseEvent, url: string) => void; } export default function MessageList({messages}: MessageListProps) { @@ -46,20 +43,14 @@ export default function MessageList({messages}: MessageListProps) { return scrollTop + clientHeight >= scrollHeight - 10; // 10px tolerance }, []); - // Regex to find URLs - // https://stackoverflow.com/a/17773849 - const urlRegex = useMemo(() => /(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, []); - - const [modifierPressed, setModifierPressed] = useState(false); - // Track Ctrl (Windows/Linux) or Cmd (Mac) key state // This is so that underline is only visible when hover + cmd/ctrl useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey || e.metaKey) setModifierPressed(true); + if (e.ctrlKey || e.metaKey) document.documentElement.classList.add('modifier-pressed'); }; const handleKeyUp = (e: KeyboardEvent) => { - if (!e.ctrlKey && !e.metaKey) setModifierPressed(false); + if (!e.ctrlKey && !e.metaKey) document.documentElement.classList.remove('modifier-pressed'); }; window.addEventListener("keydown", handleKeyDown); @@ -68,6 +59,8 @@ export default function MessageList({messages}: MessageListProps) { return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); + document.documentElement.classList.remove('modifier-pressed'); + }; }, []); @@ -125,14 +118,6 @@ export default function MessageList({messages}: MessageListProps) { lastScrollHeightRef.current = currentScrollHeight; }, [messages]); - const handleClick = useCallback(() => (e: React.MouseEvent, url: string) => { - if (e.metaKey || e.ctrlKey) { - window.open(url, "_blank"); - } else { - e.preventDefault(); // disable normal click to emulate terminal behaviour - } - }, []); - // If no messages, show a placeholder if (messages.length === 0) { return ( @@ -171,9 +156,6 @@ export default function MessageList({messages}: MessageListProps) { )} @@ -207,21 +189,29 @@ const LoadingDots = () => ( const ProcessedMessage = React.memo(function ProcessedMessage({ messageContent, index, - modifierPressed, - urlRegex, - handleClick }: ProcessedMessageProps) { + // Regex to find URLs + // https://stackoverflow.com/a/17773849 + const urlRegex = useMemo(() => /(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, []); + + const handleClick = (e: React.MouseEvent, url: string) => { + if (e.metaKey || e.ctrlKey) { + window.open(url, "_blank"); + } else { + e.preventDefault(); // disable normal click to emulate terminal behaviour + } + } + const linkedContent = useMemo(() => { return messageContent.split(urlRegex).map((content, idx) => { + console.log(content) if (urlRegex.test(content)) { return ( handleClick(e, content)} - className={`${ - modifierPressed ? "hover:underline cursor-pointer" : "cursor-default" - }`} + className="cursor-default [.modifier-pressed_&]:hover:underline [.modifier-pressed_&]:hover:cursor-pointer" > {content} @@ -229,7 +219,7 @@ const ProcessedMessage = React.memo(function ProcessedMessage({ } return {content}; }); - }, [handleClick, index, messageContent, modifierPressed, urlRegex]); + }, [index, messageContent, urlRegex]); return <>{linkedContent}; }); \ No newline at end of file From a44f3ddf44aea21f3ec4bbf8442129aabb7cd1d1 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 30 Sep 2025 21:50:54 +0530 Subject: [PATCH 4/4] chore: lint --- chat/src/components/message-list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chat/src/components/message-list.tsx b/chat/src/components/message-list.tsx index 3b94952..0b00072 100644 --- a/chat/src/components/message-list.tsx +++ b/chat/src/components/message-list.tsx @@ -1,6 +1,6 @@ "use client"; -import React, {useLayoutEffect, useRef, useEffect, useCallback, useMemo, useState} from "react"; +import React, {useLayoutEffect, useRef, useEffect, useCallback, useMemo} from "react"; interface Message { role: string;