scira-chat / components /message.tsx
mukaddamzaid's picture
feat: add CopyButton component and integrate copy functionality into messages
55947a0
raw
history blame
7.15 kB
"use client";
import type { Message as TMessage } from "ai";
import { AnimatePresence, motion } from "motion/react";
import { memo, useCallback, useEffect, useState } from "react";
import equal from "fast-deep-equal";
import { Markdown } from "./markdown";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, ChevronUpIcon, LightbulbIcon } from "lucide-react";
import { SpinnerIcon } from "./icons";
import { ToolInvocation } from "./tool-invocation";
import { CopyButton } from "./copy-button";
interface ReasoningPart {
type: "reasoning";
reasoning: string;
details: Array<{ type: "text"; text: string }>;
}
interface ReasoningMessagePartProps {
part: ReasoningPart;
isReasoning: boolean;
}
export function ReasoningMessagePart({
part,
isReasoning,
}: ReasoningMessagePartProps) {
const [isExpanded, setIsExpanded] = useState(false);
const memoizedSetIsExpanded = useCallback((value: boolean) => {
setIsExpanded(value);
}, []);
useEffect(() => {
memoizedSetIsExpanded(isReasoning);
}, [isReasoning, memoizedSetIsExpanded]);
return (
<div className="flex flex-col py-1">
{isReasoning ? (
<div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400">
<div className="animate-spin">
<SpinnerIcon />
</div>
<div className="text-sm">Thinking...</div>
</div>
) : (
<div className="flex items-center gap-2 group">
<div className="flex items-center gap-2">
<LightbulbIcon className="h-4 w-4 text-zinc-400" />
<div className="text-sm text-zinc-600 dark:text-zinc-300">Reasoning</div>
</div>
<button
className={cn(
"cursor-pointer rounded-md p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors opacity-0 group-hover:opacity-100",
{
"opacity-100 bg-zinc-100 dark:bg-zinc-800": isExpanded,
},
)}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronDownIcon className="h-3.5 w-3.5" />
) : (
<ChevronUpIcon className="h-3.5 w-3.5" />
)}
</button>
</div>
)}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
key="reasoning"
className="text-sm text-zinc-600 dark:text-zinc-400 flex flex-col gap-3 border-l-2 pl-4 mt-2 border-zinc-200 dark:border-zinc-700 overflow-hidden"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{part.details.map((detail, detailIndex) =>
detail.type === "text" ? (
<Markdown key={detailIndex}>{detail.text}</Markdown>
) : (
"<redacted>"
),
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
const PurePreviewMessage = ({
message,
isLatestMessage,
status,
}: {
message: TMessage;
isLoading: boolean;
status: "error" | "submitted" | "streaming" | "ready";
isLatestMessage: boolean;
}) => {
// Create a string with all text parts for copy functionality
const getMessageText = () => {
if (!message.parts) return "";
return message.parts
.filter(part => part.type === "text")
.map(part => (part.type === "text" ? part.text : ""))
.join("\n\n");
};
// Only show copy button if the message is from the assistant and not currently streaming
const shouldShowCopyButton = message.role === "assistant" && (!isLatestMessage || status !== "streaming");
return (
<AnimatePresence key={message.id}>
<motion.div
className={cn(
"w-full mx-auto px-4 group/message",
message.role === "assistant" ? "mb-8" : "mb-6"
)}
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
key={`message-${message.id}`}
data-role={message.role}
>
<div
className={cn(
"flex gap-4 w-full group-data-[role=user]/message:ml-auto group-data-[role=user]/message:max-w-2xl",
"group-data-[role=user]/message:w-fit",
)}
>
<div className="flex flex-col w-full space-y-3">
{message.parts?.map((part, i) => {
switch (part.type) {
case "text":
return (
<motion.div
initial={{ y: 5, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
key={`message-${message.id}-part-${i}`}
className="flex flex-row gap-2 items-start w-full"
>
<div
className={cn("flex flex-col gap-3 w-full", {
"bg-secondary text-secondary-foreground px-4 py-3 rounded-2xl":
message.role === "user",
})}
>
<Markdown>{part.text}</Markdown>
</div>
</motion.div>
);
case "tool-invocation":
const { toolName, state, args } = part.toolInvocation;
const result = 'result' in part.toolInvocation ? part.toolInvocation.result : null;
return (
<ToolInvocation
key={`message-${message.id}-part-${i}`}
toolName={toolName}
state={state}
args={args}
result={result}
isLatestMessage={isLatestMessage}
status={status}
/>
);
case "reasoning":
return (
<ReasoningMessagePart
key={`message-${message.id}-${i}`}
// @ts-expect-error part
part={part}
isReasoning={
(message.parts &&
status === "streaming" &&
i === message.parts.length - 1) ??
false
}
/>
);
default:
return null;
}
})}
{shouldShowCopyButton && (
<div className="flex justify-start mt-2">
<CopyButton text={getMessageText()} />
</div>
)}
</div>
</div>
</motion.div>
</AnimatePresence>
);
};
export const Message = memo(PurePreviewMessage, (prevProps, nextProps) => {
if (prevProps.status !== nextProps.status) return false;
if (prevProps.message.annotations !== nextProps.message.annotations)
return false;
if (!equal(prevProps.message.parts, nextProps.message.parts)) return false;
return true;
});