Skip to content

Commit c20e24f

Browse files
committed
feat: now you can copy markdown of bot response
1 parent 1034055 commit c20e24f

8 files changed

Lines changed: 195 additions & 50 deletions

File tree

package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@
3737
"class-variance-authority": "^0.7.1",
3838
"clsx": "^2.1.1",
3939
"lucide-react": "^0.476.0",
40+
"next-themes": "^0.4.6",
4041
"openai": "^4.103.0",
4142
"react": "^19.0.0",
4243
"react-dom": "^19.0.0",
4344
"react-markdown": "^10.1.0",
4445
"remark-gfm": "^4.0.1",
46+
"sonner": "^2.0.6",
4547
"tailwind-merge": "^3.0.2",
4648
"tailwindcss": "^4.0.6",
4749
"tailwindcss-animate": "^1.0.7",

src/components/BotMessage.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { cn } from '../lib/utils'
2+
import type { Message } from '../mcp/client'
3+
import { formatTimestamp } from '../lib/utils'
4+
import { Bot, Copy } from 'lucide-react'
5+
import { MarkdownContent } from './MarkdownContent'
6+
7+
import { toast } from 'sonner'
8+
import { copyToClipboard } from '../lib/utils/clipboard'
9+
10+
export interface BotMessageProps {
11+
message: Message
12+
isLoading?: boolean
13+
}
14+
15+
export function BotMessage({ message, isLoading }: BotMessageProps) {
16+
const handleCopy = async () => {
17+
const copied = await copyToClipboard(message.content)
18+
19+
if (copied) {
20+
toast.success('Copied message to clipboard')
21+
}
22+
}
23+
24+
return (
25+
<div
26+
className={cn(
27+
'flex w-full max-w-full gap-2 py-2 animate-in fade-in',
28+
'justify-start',
29+
)}
30+
>
31+
<div
32+
className={cn(
33+
'flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100',
34+
isLoading && 'animate-[pulse_1.5s_ease-in-out_infinite] opacity-80',
35+
)}
36+
>
37+
<Bot className="h-5 w-5" />
38+
</div>
39+
<div
40+
className={cn(
41+
'flex flex-col space-y-1 items-start',
42+
'w-full sm:w-[85%] md:w-[75%] lg:w-[65%]',
43+
)}
44+
>
45+
<div
46+
className={cn(
47+
'relative rounded-2xl px-4 py-2 text-sm w-full bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100',
48+
)}
49+
data-raw-markdown={message.content}
50+
>
51+
{/* Copy button */}
52+
<button
53+
type="button"
54+
aria-label="Copy raw markdown"
55+
className={cn(
56+
'absolute top-2 right-2 p-1 rounded-md transition-colors bg-transparent text-gray-400 hover:bg-gray-200 hover:text-gray-900 dark:hover:bg-gray-700 dark:hover:text-gray-100',
57+
)}
58+
onClick={handleCopy}
59+
tabIndex={0}
60+
>
61+
<Copy className="h-4 w-4" aria-hidden="true" />
62+
<span className="sr-only">Copy</span>
63+
</button>
64+
<div className="prose prose-sm dark:prose-invert max-w-none break-words break-all whitespace-pre-wrap">
65+
<MarkdownContent content={message.content} />
66+
</div>
67+
</div>
68+
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 space-x-1">
69+
<span>{formatTimestamp(message.timestamp)}</span>
70+
</div>
71+
</div>
72+
</div>
73+
)
74+
}

src/components/Chat.tsx

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useRef, useMemo, useState } from 'react'
2-
import { ChatMessage } from './ChatMessage'
2+
import { UserMessage } from './UserMessage'
3+
import { BotMessage } from './BotMessage'
34
import { ChatInput } from './ChatInput'
45
import { ServerSelector } from './ServerSelector'
56
import { useChat } from 'ai/react'
@@ -541,7 +542,7 @@ export function Chat() {
541542
{ type: 'assistant' }
542543
>
543544
return (
544-
<ChatMessage
545+
<BotMessage
545546
key={assistantEvent.id}
546547
message={{
547548
id: assistantEvent.id,
@@ -559,7 +560,7 @@ export function Chat() {
559560
{ type: 'user' }
560561
>
561562
return (
562-
<ChatMessage
563+
<UserMessage
563564
key={userEvent.id}
564565
message={{
565566
id: userEvent.id,
@@ -568,29 +569,42 @@ export function Chat() {
568569
timestamp: new Date(),
569570
status: 'sent',
570571
}}
571-
isLoading={false}
572572
/>
573573
)
574574
} else {
575575
// Fallback for Message type (from useChat)
576576
const message = event as Message
577-
return (
578-
<ChatMessage
579-
key={message.id}
580-
message={{
581-
id: message.id,
582-
content: message.content,
583-
sender: message.role === 'assistant' ? 'agent' : 'user',
584-
timestamp: new Date(),
585-
status: 'sent',
586-
}}
587-
isLoading={
588-
(isLoading || streaming) &&
589-
message.role === 'assistant' &&
590-
message === messages.at(-1)
591-
}
592-
/>
593-
)
577+
const isAssistant = message.role === 'assistant'
578+
if (isAssistant) {
579+
return (
580+
<BotMessage
581+
key={message.id}
582+
message={{
583+
id: message.id,
584+
content: message.content,
585+
sender: 'agent',
586+
timestamp: new Date(),
587+
status: 'sent',
588+
}}
589+
isLoading={
590+
(isLoading || streaming) && message === messages.at(-1)
591+
}
592+
/>
593+
)
594+
} else {
595+
return (
596+
<UserMessage
597+
key={message.id}
598+
message={{
599+
id: message.id,
600+
content: message.content,
601+
sender: 'user',
602+
timestamp: new Date(),
603+
status: 'sent',
604+
}}
605+
/>
606+
)
607+
}
594608
}
595609
})}
596610
{streaming && <BotThinking />}
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,29 @@
11
import { cn } from '../lib/utils'
22
import type { Message } from '../mcp/client'
33
import { formatTimestamp } from '../lib/utils'
4-
import { Bot, User, CheckCircle2, Clock, AlertCircle } from 'lucide-react'
4+
import { User, CheckCircle2, Clock, AlertCircle } from 'lucide-react'
55
import { MarkdownContent } from './MarkdownContent'
66

7-
type ChatMessageProps = {
7+
export interface UserMessageProps {
88
message: Message
9-
isLoading?: boolean
109
}
1110

12-
export function ChatMessage({ message, isLoading }: ChatMessageProps) {
13-
const isUserMessage = message.sender === 'user'
14-
11+
export function UserMessage({ message }: UserMessageProps) {
1512
const statusIcons = {
1613
sending: <Clock className="h-3 w-3 text-gray-400" />,
1714
sent: <CheckCircle2 className="h-3 w-3 text-green-500" />,
1815
error: <AlertCircle className="h-3 w-3 text-red-500" />,
1916
}
20-
2117
return (
2218
<div
2319
className={cn(
2420
'flex w-full max-w-full gap-2 py-2 animate-in fade-in',
25-
isUserMessage ? 'justify-end' : 'justify-start',
21+
'justify-end',
2622
)}
2723
>
28-
{!isUserMessage && (
29-
<div
30-
className={cn(
31-
'flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100',
32-
isLoading && 'animate-[pulse_1.5s_ease-in-out_infinite] opacity-80',
33-
)}
34-
>
35-
<Bot className="h-5 w-5" />
36-
</div>
37-
)}
38-
3924
<div
4025
className={cn(
41-
'flex flex-col space-y-1',
42-
isUserMessage ? 'items-end' : 'items-start',
26+
'flex flex-col space-y-1 items-end',
4327
'w-full sm:w-[85%] md:w-[75%] lg:w-[65%]',
4428
)}
4529
>
@@ -52,18 +36,14 @@ export function ChatMessage({ message, isLoading }: ChatMessageProps) {
5236
<MarkdownContent content={message.content} />
5337
</div>
5438
</div>
55-
5639
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 space-x-1">
5740
<span>{formatTimestamp(message.timestamp)}</span>
58-
{isUserMessage && statusIcons[message.status]}
41+
{statusIcons[message.status]}
5942
</div>
6043
</div>
61-
62-
{isUserMessage && (
63-
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100">
64-
<User className="h-5 w-5" />
65-
</div>
66-
)}
44+
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100">
45+
<User className="h-5 w-5" />
46+
</div>
6747
</div>
6848
)
6949
}

src/components/ui/sonner.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useTheme } from 'next-themes'
2+
import { Toaster as Sonner } from 'sonner'
3+
import type { ToasterProps } from 'sonner'
4+
5+
const Toaster = ({ ...props }: ToasterProps) => {
6+
const { theme = 'system' } = useTheme()
7+
8+
return (
9+
<Sonner
10+
theme={theme as ToasterProps['theme']}
11+
className="toaster group"
12+
style={
13+
{
14+
'--normal-bg': 'var(--popover)',
15+
'--normal-text': 'var(--popover-foreground)',
16+
'--normal-border': 'var(--border)',
17+
} as React.CSSProperties
18+
}
19+
{...props}
20+
/>
21+
)
22+
}
23+
24+
export { Toaster }

src/lib/utils/clipboard.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Utility to copy text to clipboard, with iOS fallback
2+
// Returns a boolean indicating success
3+
export async function copyToClipboard(text: string): Promise<boolean> {
4+
let copied = false
5+
if (navigator.clipboard && window.isSecureContext) {
6+
try {
7+
await navigator.clipboard.writeText(text)
8+
copied = true
9+
} catch {}
10+
}
11+
if (!copied) {
12+
const el = document.createElement('textarea')
13+
el.value = text
14+
el.setAttribute('readonly', '')
15+
el.style.position = 'absolute'
16+
el.style.left = '-9999px'
17+
document.body.appendChild(el)
18+
el.select()
19+
el.setSelectionRange(0, el.value.length)
20+
try {
21+
document.execCommand('copy')
22+
copied = true
23+
} catch {}
24+
document.body.removeChild(el)
25+
}
26+
return copied
27+
}

src/routes/__root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { UserProvider } from '../contexts/UserContext'
1111

1212
import appCss from '../styles.css?url'
1313
import Header from '../components/Header'
14+
import { Toaster } from '../components/ui/sonner'
1415

1516
// Create a client
1617
const queryClient = new QueryClient()
@@ -84,6 +85,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
8485
<div className="mx-auto max-w-4xl h-full">{children}</div>
8586
</main>
8687
</div>
88+
<Toaster />
8789
<Scripts />
8890
</body>
8991
</html>

0 commit comments

Comments
 (0)