11"use client" ;
22
3- import { useLayoutEffect , useRef , useEffect , useCallback } from "react" ;
3+ import React , { useLayoutEffect , useRef , useEffect , useCallback , useMemo , useState } from "react" ;
44
55interface 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 > ( ( ) => / ( h t t p s ? : \/ \/ (?: w w w \. | (? ! w w w ) ) [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 - ] + [ a - z A - Z 0 - 9 ] \. [ ^ \s ] { 2 , } | w w w \. [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 - ] + [ a - z A - Z 0 - 9 ] \. [ ^ \s ] { 2 , } | h t t p s ? : \/ \/ (?: w w w \. | (? ! w w w ) ) [ a - z A - Z 0 - 9 ] + \. [ ^ \s ] { 2 , } | w w w \. [ a - z A - Z 0 - 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