Skip to content

Commit 6e6de1e

Browse files
huminglongCopilotDayuanJiang
authored
feat: add copy button to user messages in chat sidebar (#21)
* Initial plan * Initial plan for adding copy button to user messages Co-authored-by: huminglong <[email protected]> * Add copy button to user messages in chat sidebar Co-authored-by: huminglong <[email protected]> * Refactor: extract userMessageText to avoid duplicate function calls Co-authored-by: huminglong <[email protected]> * feat(ui): 增加消息复制功能支持非 HTTPS 环境 优化复制消息到剪贴板的逻辑,增加对非 HTTPS 环境的降级处理,提升用户体验。 * fix(package): 添加 peer 属性以支持依赖关系 * chore(.gitignore): 添加 next 和 [email protected] 相关条目 * fix: improve copy button implementation - Fix copy button alignment (place left of user message) - Remove unused React import - Move getMessageTextContent outside component - Add error state with X icon for copy failures - Translate Chinese comments to English - Simplify copy function - Add missing semicolon - Remove accidental .gitignore entries --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: dayuan.jiang <[email protected]>
1 parent 00af87e commit 6e6de1e

File tree

4 files changed

+5278
-522
lines changed

4 files changed

+5278
-522
lines changed

.eslintrc.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": [
3+
"next/core-web-vitals",
4+
"next/typescript"
5+
]
6+
}

components/chat-message-display.tsx

Lines changed: 85 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
"use client";
22

3-
import type React from "react";
43
import { useRef, useEffect, useState, useCallback } from "react";
54
import Image from "next/image";
65
import { ScrollArea } from "@/components/ui/scroll-area";
76
import ExamplePanel from "./chat-example-panel";
87
import { UIMessage } from "ai";
98
import { convertToLegalXml, replaceNodes } from "@/lib/utils";
9+
import { Copy, Check, X } from "lucide-react";
1010

1111
import { useDiagram } from "@/contexts/diagram-context";
1212

13+
const getMessageTextContent = (message: UIMessage): string => {
14+
if (!message.parts) return "";
15+
return message.parts
16+
.filter((part: any) => part.type === "text")
17+
.map((part: any) => part.text)
18+
.join("\n");
19+
};
20+
1321
interface ChatMessageDisplayProps {
1422
messages: UIMessage[];
1523
error?: Error | null;
@@ -30,6 +38,21 @@ export function ChatMessageDisplay({
3038
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>(
3139
{}
3240
);
41+
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
42+
const [copyFailedMessageId, setCopyFailedMessageId] = useState<string | null>(null);
43+
44+
const copyMessageToClipboard = async (messageId: string, text: string) => {
45+
try {
46+
await navigator.clipboard.writeText(text);
47+
setCopiedMessageId(messageId);
48+
setTimeout(() => setCopiedMessageId(null), 2000);
49+
} catch (err) {
50+
console.error("Failed to copy message:", err);
51+
setCopyFailedMessageId(messageId);
52+
setTimeout(() => setCopyFailedMessageId(null), 2000);
53+
}
54+
};
55+
3356
const handleDisplayChart = useCallback(
3457
(xml: string) => {
3558
const currentXml = xml || "";
@@ -137,16 +160,16 @@ export function ChatMessageDisplay({
137160
{output || (toolName === "display_diagram"
138161
? "Diagram generated"
139162
: toolName === "edit_diagram"
140-
? "Diagram edited"
141-
: "Tool executed")}
163+
? "Diagram edited"
164+
: "Tool executed")}
142165
</div>
143166
) : state === "output-error" ? (
144167
<div className="text-red-600">
145168
{output || (toolName === "display_diagram"
146169
? "Error generating diagram"
147170
: toolName === "edit_diagram"
148-
? "Error editing diagram"
149-
: "Tool error")}
171+
? "Error editing diagram"
172+
: "Tool error")}
150173
</div>
151174
) : null}
152175
</div>
@@ -160,51 +183,66 @@ export function ChatMessageDisplay({
160183
{messages.length === 0 ? (
161184
<ExamplePanel setInput={setInput} setFiles={setFiles} />
162185
) : (
163-
messages.map((message) => (
164-
<div
165-
key={message.id}
166-
className={`mb-4 ${
167-
message.role === "user" ? "text-right" : "text-left"
168-
}`}
169-
>
186+
messages.map((message) => {
187+
const userMessageText = message.role === "user" ? getMessageTextContent(message) : "";
188+
return (
170189
<div
171-
className={`inline-block px-4 py-2 whitespace-pre-wrap text-sm rounded-lg max-w-[85%] break-words ${
172-
message.role === "user"
173-
? "bg-primary text-primary-foreground"
174-
: "bg-muted text-muted-foreground"
175-
}`}
190+
key={message.id}
191+
className={`mb-4 flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
176192
>
177-
{message.parts?.map((part: any, index: number) => {
178-
switch (part.type) {
179-
case "text":
180-
return (
181-
<div key={index}>{part.text}</div>
182-
);
183-
case "file":
184-
return (
185-
<div key={index} className="mt-2">
186-
<Image
187-
src={part.url}
188-
width={200}
189-
height={200}
190-
alt={`Uploaded diagram or image for AI analysis`}
191-
className="rounded-md border"
192-
style={{
193-
objectFit: "contain",
194-
}}
195-
/>
196-
</div>
197-
);
198-
default:
199-
if (part.type?.startsWith("tool-")) {
200-
return renderToolPart(part);
201-
}
202-
return null;
203-
}
204-
})}
193+
{message.role === "user" && userMessageText && (
194+
<button
195+
onClick={() => copyMessageToClipboard(message.id, userMessageText)}
196+
className="p-1 text-gray-400 hover:text-gray-600 transition-colors self-center mr-1"
197+
title={copiedMessageId === message.id ? "Copied!" : copyFailedMessageId === message.id ? "Failed to copy" : "Copy message"}
198+
>
199+
{copiedMessageId === message.id ? (
200+
<Check className="h-3.5 w-3.5 text-green-500" />
201+
) : copyFailedMessageId === message.id ? (
202+
<X className="h-3.5 w-3.5 text-red-500" />
203+
) : (
204+
<Copy className="h-3.5 w-3.5" />
205+
)}
206+
</button>
207+
)}
208+
<div
209+
className={`px-4 py-2 whitespace-pre-wrap text-sm rounded-lg max-w-[85%] break-words ${message.role === "user"
210+
? "bg-primary text-primary-foreground"
211+
: "bg-muted text-muted-foreground"
212+
}`}
213+
>
214+
{message.parts?.map((part: any, index: number) => {
215+
switch (part.type) {
216+
case "text":
217+
return (
218+
<div key={index}>{part.text}</div>
219+
);
220+
case "file":
221+
return (
222+
<div key={index} className="mt-2">
223+
<Image
224+
src={part.url}
225+
width={200}
226+
height={200}
227+
alt={`Uploaded diagram or image for AI analysis`}
228+
className="rounded-md border"
229+
style={{
230+
objectFit: "contain",
231+
}}
232+
/>
233+
</div>
234+
);
235+
default:
236+
if (part.type?.startsWith("tool-")) {
237+
return renderToolPart(part);
238+
}
239+
return null;
240+
}
241+
})}
242+
</div>
205243
</div>
206-
</div>
207-
))
244+
);
245+
})
208246
)}
209247
{error && (
210248
<div className="text-red-500 text-sm mt-2">

0 commit comments

Comments
 (0)