1
1
"use client" ;
2
2
3
- import { useLayoutEffect , useRef , useEffect , useCallback } from "react" ;
3
+ import React , { useLayoutEffect , useRef , useEffect , useCallback , useMemo } from "react" ;
4
4
5
5
interface Message {
6
6
role : string ;
@@ -18,7 +18,12 @@ interface MessageListProps {
18
18
messages : ( Message | DraftMessage ) [ ] ;
19
19
}
20
20
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 ) {
22
27
const scrollAreaRef = useRef < HTMLDivElement > ( null ) ;
23
28
24
29
// Track if user is at bottom - default to true for initial scroll
@@ -32,6 +37,27 @@ export default function MessageList({ messages }: MessageListProps) {
32
37
return scrollTop + clientHeight >= scrollHeight - 10 ; // 10px tolerance
33
38
} , [ ] ) ;
34
39
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
+
35
61
// Update isAtBottom on scroll
36
62
useEffect ( ( ) => {
37
63
const scrollContainer = scrollAreaRef . current ;
@@ -94,7 +120,7 @@ export default function MessageList({ messages }: MessageListProps) {
94
120
< div className = "overflow-y-auto flex-1" ref = { scrollAreaRef } >
95
121
< div
96
122
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 ) => (
98
124
< div
99
125
key = { message . id ?? "draft" }
100
126
className = { `${ message . role === "user" ? "text-right" : "" } ` }
@@ -114,7 +140,10 @@ export default function MessageList({ messages }: MessageListProps) {
114
140
{ message . role !== "user" && message . content === "" ? (
115
141
< LoadingDots />
116
142
) : (
117
- message . content . trimEnd ( )
143
+ < ProcessedMessage
144
+ messageContent = { message . content }
145
+ index = { index }
146
+ />
118
147
) }
119
148
</ div >
120
149
</ div >
@@ -142,3 +171,42 @@ const LoadingDots = () => (
142
171
< span className = "sr-only" > Loading...</ span >
143
172
</ div >
144
173
) ;
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