11"use client" ;
22
3- import { useLayoutEffect , useRef , useEffect , useCallback } from "react" ;
3+ import React , { useLayoutEffect , useRef , useEffect , useCallback , useMemo } from "react" ;
44
55interface Message {
66 role : string ;
@@ -18,7 +18,12 @@ interface MessageListProps {
1818 messages : ( Message | DraftMessage ) [ ] ;
1919}
2020
21- export default function MessageList ( { messages } : MessageListProps ) {
21+ interface ProcessedMessageProps {
22+ messageContent : string ;
23+ index : number ;
24+ }
25+
26+ export default function MessageList ( { messages} : MessageListProps ) {
2227 const scrollAreaRef = useRef < HTMLDivElement > ( null ) ;
2328
2429 // Track if user is at bottom - default to true for initial scroll
@@ -32,6 +37,27 @@ export default function MessageList({ messages }: MessageListProps) {
3237 return scrollTop + clientHeight >= scrollHeight - 10 ; // 10px tolerance
3338 } , [ ] ) ;
3439
40+ // Track Ctrl (Windows/Linux) or Cmd (Mac) key state
41+ // This is so that underline is only visible when hover + cmd/ctrl
42+ useEffect ( ( ) => {
43+ const handleKeyDown = ( e : KeyboardEvent ) => {
44+ if ( e . ctrlKey || e . metaKey ) document . documentElement . classList . add ( 'modifier-pressed' ) ;
45+ } ;
46+ const handleKeyUp = ( e : KeyboardEvent ) => {
47+ if ( ! e . ctrlKey && ! e . metaKey ) document . documentElement . classList . remove ( 'modifier-pressed' ) ;
48+ } ;
49+
50+ window . addEventListener ( "keydown" , handleKeyDown ) ;
51+ window . addEventListener ( "keyup" , handleKeyUp ) ;
52+
53+ return ( ) => {
54+ window . removeEventListener ( "keydown" , handleKeyDown ) ;
55+ window . removeEventListener ( "keyup" , handleKeyUp ) ;
56+ document . documentElement . classList . remove ( 'modifier-pressed' ) ;
57+
58+ } ;
59+ } , [ ] ) ;
60+
3561 // Update isAtBottom on scroll
3662 useEffect ( ( ) => {
3763 const scrollContainer = scrollAreaRef . current ;
@@ -94,7 +120,7 @@ export default function MessageList({ messages }: MessageListProps) {
94120 < div className = "overflow-y-auto flex-1" ref = { scrollAreaRef } >
95121 < div
96122 className = "p-4 flex flex-col gap-4 max-w-4xl mx-auto transition-all duration-300 ease-in-out min-h-0" >
97- { messages . map ( ( message ) => (
123+ { messages . map ( ( message , index ) => (
98124 < div
99125 key = { message . id ?? "draft" }
100126 className = { `${ message . role === "user" ? "text-right" : "" } ` }
@@ -114,7 +140,10 @@ export default function MessageList({ messages }: MessageListProps) {
114140 { message . role !== "user" && message . content === "" ? (
115141 < LoadingDots />
116142 ) : (
117- message . content . trimEnd ( )
143+ < ProcessedMessage
144+ messageContent = { message . content }
145+ index = { index }
146+ />
118147 ) }
119148 </ div >
120149 </ div >
@@ -142,3 +171,42 @@ const LoadingDots = () => (
142171 < span className = "sr-only" > Loading...</ span >
143172 </ div >
144173) ;
174+
175+
176+ const ProcessedMessage = React . memo ( function ProcessedMessage ( {
177+ messageContent,
178+ index,
179+ } : ProcessedMessageProps ) {
180+ // Regex to find URLs
181+ // https://stackoverflow.com/a/17773849
182+ 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, [ ] ) ;
183+
184+ const handleClick = ( e : React . MouseEvent < HTMLAnchorElement > , url : string ) => {
185+ if ( e . metaKey || e . ctrlKey ) {
186+ window . open ( url , "_blank" ) ;
187+ } else {
188+ e . preventDefault ( ) ; // disable normal click to emulate terminal behaviour
189+ }
190+ }
191+
192+ const linkedContent = useMemo ( ( ) => {
193+ return messageContent . split ( urlRegex ) . map ( ( content , idx ) => {
194+ console . log ( content )
195+ if ( urlRegex . test ( content ) ) {
196+ return (
197+ < a
198+ key = { `${ index } -${ idx } ` }
199+ href = { content }
200+ onClick = { ( e ) => handleClick ( e , content ) }
201+ className = "cursor-default [.modifier-pressed_&]:hover:underline [.modifier-pressed_&]:hover:cursor-pointer"
202+ >
203+ { content }
204+ </ a >
205+ ) ;
206+ }
207+ return < span key = { `${ index } -${ idx } ` } > { content } </ span > ;
208+ } ) ;
209+ } , [ index , messageContent , urlRegex ] ) ;
210+
211+ return < > { linkedContent } </ > ;
212+ } ) ;
0 commit comments