Skip to content

Commit 36b1f73

Browse files
committed
Add copy button to chat and agent chat
1 parent a5da69c commit 36b1f73

6 files changed

Lines changed: 153 additions & 50 deletions

File tree

ee/ui-component/components/chat/AgentChatMessages.tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React, { useEffect, useState } from "react";
22
import { Badge } from "@/components/ui/badge";
3+
import { Button } from "@/components/ui/button";
34
import { PreviewMessage, UIMessage } from "./ChatMessages";
5+
import { Copy, Check } from "./icons";
46
import ReactMarkdown from "react-markdown";
57
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
68
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
@@ -27,6 +29,33 @@ const scrollbarStyles = `
2729
}
2830
`;
2931

32+
// Copy button component for agent messages
33+
function AgentCopyButton({ content }: { content: string }) {
34+
const [copied, setCopied] = React.useState(false);
35+
36+
const handleCopy = async () => {
37+
try {
38+
await navigator.clipboard.writeText(content);
39+
setCopied(true);
40+
setTimeout(() => setCopied(false), 2000);
41+
} catch (err) {
42+
console.error("Failed to copy text: ", err);
43+
}
44+
};
45+
46+
return (
47+
<Button
48+
variant="ghost"
49+
size="sm"
50+
className="h-8 w-8 p-0"
51+
onClick={handleCopy}
52+
title={copied ? "Copied!" : "Copy message"}
53+
>
54+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
55+
</Button>
56+
);
57+
}
58+
3059
// Base interface for display objects
3160
export interface BaseDisplayObject {
3261
source: string; // Source ID that links to the source
@@ -401,13 +430,22 @@ export function AgentPreviewMessage({ message }: AgentMessageProps) {
401430
return <PreviewMessage message={message} />;
402431
}
403432

433+
// Combine all text content from display objects for copying
434+
const fullContent = displayObjects
435+
.filter(obj => obj.type === "text")
436+
.map(obj => obj.content)
437+
.join("\n\n");
438+
404439
// Show only display objects for assistant messages that have them
405440
return (
406441
<div className="group relative flex px-4 py-3">
407442
<div className="flex w-full flex-col items-start">
408443
<div className="flex w-full max-w-3xl items-start gap-4">
409444
<div className="flex-1 space-y-2 overflow-hidden">
410-
<div className="rounded-xl bg-muted p-4">
445+
<div className="relative rounded-xl bg-muted p-4">
446+
<div className="absolute right-2 top-2">
447+
<AgentCopyButton content={fullContent} />
448+
</div>
411449
<div className="space-y-3">
412450
{displayObjects.map((obj, idx) => (
413451
<DisplayObjectRenderer key={idx} object={obj} />

ee/ui-component/components/chat/ChatMessages.tsx

Lines changed: 63 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import React from "react";
22
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
33
import { Badge } from "@/components/ui/badge";
4-
import { Spin } from "./icons";
4+
import { Button } from "@/components/ui/button";
5+
import { Copy, Check, Spin } from "./icons";
56
import Image from "next/image";
67
import { Source } from "@/components/types";
78
import ReactMarkdown from "react-markdown";
89
import remarkGfm from "remark-gfm";
910
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
1011
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
11-
import type { Options } from "react-markdown";
1212

1313
// Define interface for the UIMessage - matching what our useMorphikChat hook returns
1414
export interface UIMessage {
@@ -68,6 +68,33 @@ const renderContent = (content: string, contentType: string) => {
6868
}
6969
};
7070

71+
// Copy button component
72+
function CopyButton({ content }: { content: string }) {
73+
const [copied, setCopied] = React.useState(false);
74+
75+
const handleCopy = async () => {
76+
try {
77+
await navigator.clipboard.writeText(content);
78+
setCopied(true);
79+
setTimeout(() => setCopied(false), 2000);
80+
} catch (err) {
81+
console.error("Failed to copy text: ", err);
82+
}
83+
};
84+
85+
return (
86+
<Button
87+
variant="ghost"
88+
size="sm"
89+
className="h-8 w-8 p-0"
90+
onClick={handleCopy}
91+
title={copied ? "Copied!" : "Copy message"}
92+
>
93+
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
94+
</Button>
95+
);
96+
}
97+
7198
export function PreviewMessage({ message }: Pick<MessageProps, "message">) {
7299
const sources = message.experimental_customData?.sources;
73100

@@ -77,47 +104,50 @@ export function PreviewMessage({ message }: Pick<MessageProps, "message">) {
77104
<div className="flex w-full max-w-3xl items-start gap-4">
78105
<div className={`flex-1 space-y-2 overflow-hidden ${message.role === "user" ? "" : ""}`}>
79106
<div
80-
className={`rounded-xl p-4 ${
107+
className={`relative rounded-xl p-4 ${
81108
message.role === "user" ? "ml-auto bg-primary text-primary-foreground" : "bg-muted"
82109
}`}
83110
>
111+
{message.role === "assistant" && (
112+
<div className="absolute right-2 top-2">
113+
<CopyButton content={message.content} />
114+
</div>
115+
)}
84116
<div className="prose prose-sm dark:prose-invert max-w-none break-words">
85117
{message.role === "assistant" ? (
86118
<ReactMarkdown
87119
remarkPlugins={[remarkGfm]}
88-
components={
89-
{
90-
code(props) {
91-
const { children, className, ...rest } = props;
92-
const inline = !className?.includes("language-");
93-
const match = /language-(\w+)/.exec(className || "");
120+
components={{
121+
code(props) {
122+
const { children, className, ...rest } = props;
123+
const inline = !className?.includes("language-");
124+
const match = /language-(\w+)/.exec(className || "");
94125

95-
if (!inline && match) {
96-
const language = match[1];
97-
return (
98-
<div className="my-4 overflow-hidden rounded-md">
99-
<SyntaxHighlighter style={oneDark} language={language} PreTag="div" className="!my-0">
100-
{String(children).replace(/\n$/, "")}
101-
</SyntaxHighlighter>
102-
</div>
103-
);
104-
} else if (!inline) {
105-
return (
106-
<div className="my-4 overflow-hidden rounded-md">
107-
<SyntaxHighlighter style={oneDark} language="text" PreTag="div" className="!my-0">
108-
{String(children).replace(/\n$/, "")}
109-
</SyntaxHighlighter>
110-
</div>
111-
);
112-
}
126+
if (!inline && match) {
127+
const language = match[1];
128+
return (
129+
<div className="my-4 overflow-hidden rounded-md">
130+
<SyntaxHighlighter style={oneDark} language={language} PreTag="div" className="!my-0">
131+
{String(children).replace(/\n$/, "")}
132+
</SyntaxHighlighter>
133+
</div>
134+
);
135+
} else if (!inline) {
113136
return (
114-
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm" {...rest}>
115-
{children}
116-
</code>
137+
<div className="my-4 overflow-hidden rounded-md">
138+
<SyntaxHighlighter style={oneDark} language="text" PreTag="div" className="!my-0">
139+
{String(children).replace(/\n$/, "")}
140+
</SyntaxHighlighter>
141+
</div>
117142
);
118-
},
119-
} as Options["components"]
120-
}
143+
}
144+
return (
145+
<code className="rounded bg-muted px-1 py-0.5 font-mono text-sm" {...rest}>
146+
{children}
147+
</code>
148+
);
149+
},
150+
}}
121151
>
122152
{message.content}
123153
</ReactMarkdown>

ee/ui-component/components/chat/icons.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,40 @@ export function Sparkles({ className, ...props }: IconProps) {
115115
</svg>
116116
);
117117
}
118+
119+
export function Copy({ className, ...props }: IconProps) {
120+
return (
121+
<svg
122+
xmlns="http://www.w3.org/2000/svg"
123+
viewBox="0 0 24 24"
124+
fill="none"
125+
stroke="currentColor"
126+
strokeWidth="2"
127+
strokeLinecap="round"
128+
strokeLinejoin="round"
129+
className={`size-4 ${className}`}
130+
{...props}
131+
>
132+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
133+
<path d="m4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
134+
</svg>
135+
);
136+
}
137+
138+
export function Check({ className, ...props }: IconProps) {
139+
return (
140+
<svg
141+
xmlns="http://www.w3.org/2000/svg"
142+
viewBox="0 0 24 24"
143+
fill="none"
144+
stroke="currentColor"
145+
strokeWidth="2"
146+
strokeLinecap="round"
147+
strokeLinejoin="round"
148+
className={`size-4 ${className}`}
149+
{...props}
150+
>
151+
<path d="M20 6 9 17l-5-5" />
152+
</svg>
153+
);
154+
}

ee/ui-component/components/connectors/ConnectorCard.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
ingestConnectorFile,
99
submitManualCredentials,
1010
type ConnectorAuthStatus,
11-
type ManualAuthResponse,
1211
type CredentialField,
1312
} from "@/lib/connectorsApi";
1413
import { Button } from "@/components/ui/button";
@@ -82,10 +81,15 @@ export function ConnectorCard({
8281
connectionsSectionUrl.pathname = "/"; // Ensure we are at the root path
8382
connectionsSectionUrl.searchParams.set("section", "connections");
8483

85-
const authResponse = await initiateConnectorAuth(apiBaseUrl, connectorType, connectionsSectionUrl.toString(), authToken);
84+
const authResponse = await initiateConnectorAuth(
85+
apiBaseUrl,
86+
connectorType,
87+
connectionsSectionUrl.toString(),
88+
authToken
89+
);
8690

8791
// Check if this is a manual credentials flow
88-
if ('auth_type' in authResponse && authResponse.auth_type === 'manual_credentials') {
92+
if ("auth_type" in authResponse && authResponse.auth_type === "manual_credentials") {
8993
// Handle manual credentials flow
9094
setCredentialFields(authResponse.required_fields);
9195
setCredentialInstructions(authResponse.instructions || "");
@@ -293,28 +297,24 @@ export function ConnectorCard({
293297
</div>
294298
)}
295299

296-
{credentialFields.map((field) => (
300+
{credentialFields.map(field => (
297301
<div key={field.name} className="space-y-2">
298302
<Label htmlFor={field.name}>
299303
{field.label}
300304
{field.required && <span className="text-red-500">*</span>}
301305
</Label>
302-
{field.description && (
303-
<p className="text-sm text-gray-600 dark:text-gray-400">{field.description}</p>
304-
)}
306+
{field.description && <p className="text-sm text-gray-600 dark:text-gray-400">{field.description}</p>}
305307

306308
{field.type === "select" ? (
307309
<Select
308310
value={credentialValues[field.name] || ""}
309-
onValueChange={(value) =>
310-
setCredentialValues(prev => ({ ...prev, [field.name]: value }))
311-
}
311+
onValueChange={value => setCredentialValues(prev => ({ ...prev, [field.name]: value }))}
312312
>
313313
<SelectTrigger>
314314
<SelectValue placeholder={`Select ${field.label}`} />
315315
</SelectTrigger>
316316
<SelectContent>
317-
{field.options?.map((option) => (
317+
{field.options?.map(option => (
318318
<SelectItem key={option.value} value={option.value}>
319319
{option.label}
320320
</SelectItem>
@@ -326,9 +326,7 @@ export function ConnectorCard({
326326
id={field.name}
327327
type={field.type}
328328
value={credentialValues[field.name] || ""}
329-
onChange={(e) =>
330-
setCredentialValues(prev => ({ ...prev, [field.name]: e.target.value }))
331-
}
329+
onChange={e => setCredentialValues(prev => ({ ...prev, [field.name]: e.target.value }))}
332330
placeholder={field.description}
333331
required={field.required}
334332
/>

ee/ui-component/lib/connectorsApi.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export async function initiateConnectorAuth(
109109
export async function submitManualCredentials(
110110
apiBaseUrl: string,
111111
connectorType: string,
112-
credentials: Record<string, any>,
112+
credentials: Record<string, string>,
113113
authToken: string | null
114114
): Promise<{ status: string; message: string }> {
115115
const response = await fetch(`${apiBaseUrl}/ee/connectors/${connectorType}/auth/finalize`, {

ee/ui-component/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@morphik/ui",
3-
"version": "0.2.26",
3+
"version": "0.2.27",
44
"private": true,
55
"description": "Modern UI component for Morphik - A powerful document processing and querying system",
66
"author": "Morphik Team",

0 commit comments

Comments
 (0)