Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 117 additions & 87 deletions src/apps/chats/components/ChatMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { UIMessage as VercelMessage } from "@ai-sdk/react";
import { WarningCircle, ChatCircle, Copy, Check, CaretDown, Trash, SpeakerHigh, Pause, PaperPlaneRight } from "@phosphor-icons/react";
import { useEffect, useRef, useState, memo } from "react";
import { useEffect, useMemo, useRef, useState, memo } from "react";
import { Button } from "@/components/ui/button";
import { ActivityIndicator } from "@/components/ui/activity-indicator";
import { AnimatePresence, motion } from "framer-motion";
Expand Down Expand Up @@ -106,6 +106,8 @@ const getCitationLabel = (url: string): string => {
}
};

const measureVisibleLength = (text: string) => text.replace(/\s+/g, "").length;

// Helper function to extract user-friendly error message
const getErrorMessage = (error: Error): string => {
if (!error.message) return "An error occurred";
Expand Down Expand Up @@ -302,12 +304,12 @@ interface ChatMessageItemProps {
isRoomView: boolean;
fontSize: number;
currentTheme: string;
copiedMessageId: string | null;
hoveredMessageId: string | null;
playingMessageId: string | null;
speechLoadingId: string | null;
highlightSegment: { messageId: string; start: number; end: number } | null;
localHighlightSegment: { messageId: string; start: number; end: number } | null;
isCopied: boolean;
isHovered: boolean;
isPlaying: boolean;
isSpeechLoading: boolean;
highlightSegment: { start: number; end: number } | null;
localHighlightSegment: { start: number; end: number } | null;
isSpeaking: boolean;
localTtsSpeaking: boolean;
speechEnabled: boolean;
Expand Down Expand Up @@ -344,10 +346,10 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
isRoomView,
fontSize,
currentTheme,
copiedMessageId,
hoveredMessageId,
playingMessageId,
speechLoadingId,
isCopied,
isHovered,
isPlaying,
isSpeechLoading,
highlightSegment,
localHighlightSegment,
isSpeaking,
Expand Down Expand Up @@ -415,21 +417,37 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
const combinedHighlightSeg = highlightSegment || localHighlightSegment;
const combinedIsSpeaking = isSpeaking || localTtsSpeaking;
const highlightActive =
combinedIsSpeaking &&
combinedHighlightSeg &&
combinedHighlightSeg.messageId === message.id;
combinedIsSpeaking && combinedHighlightSeg !== null;

const isTouchDevice = () =>
"ontouchstart" in window || navigator.maxTouchPoints > 0;

const extractUrls = (content: string): string[] => {
const extractUrls = (tokens: ChatMarkdownToken[]): string[] => {
const urls = new Set<string>();
segmentChatMarkdownText(content).forEach((token) => {
tokens.forEach((token) => {
if (token.type === "link" && token.url) urls.add(token.url);
});
return Array.from(urls);
};

const trimmedDisplayContent = useMemo(
() => displayContent.trim(),
[displayContent]
);

const messageTokens = useMemo(
() =>
displayContent
? segmentChatMarkdownText(displayContent)
: [],
[displayContent]
);

const messageUrls = useMemo(
() => extractUrls(messageTokens),
[messageTokens]
);

const renderInlineToken = (segment: ChatMarkdownToken) => {
const tokenNode =
(segment.type === "link" || segment.type === "citation") && segment.url ? (
Expand Down Expand Up @@ -525,7 +543,7 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: hoveredMessageId === messageKey ? 1 : 0,
opacity: isHovered ? 1 : 0,
scale: 1,
}}
className="h-3 w-3 text-gray-400 hover:text-red-600 transition-colors"
Expand All @@ -544,14 +562,14 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: hoveredMessageId === messageKey ? 1 : 0,
opacity: isHovered ? 1 : 0,
scale: 1,
}}
className="h-3 w-3 text-gray-400 hover:text-neutral-600 transition-colors"
onClick={() => onCopyMessage(message)}
aria-label={t("apps.chats.ariaLabels.copyMessage")}
>
{copiedMessageId === messageKey ? (
{isCopied ? (
<Check className="h-3 w-3" weight="bold" />
) : (
<Copy className="h-3 w-3" weight="bold" />
Expand Down Expand Up @@ -601,14 +619,14 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: hoveredMessageId === messageKey ? 1 : 0,
opacity: isHovered ? 1 : 0,
scale: 1,
}}
className="h-3 w-3 text-gray-400 hover:text-neutral-600 transition-colors"
onClick={() => onCopyMessage(message)}
aria-label={t("apps.chats.ariaLabels.copyMessage")}
>
{copiedMessageId === messageKey ? (
{isCopied ? (
<Check className="h-3 w-3" weight="bold" />
) : (
<Copy className="h-3 w-3" weight="bold" />
Expand All @@ -618,20 +636,20 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: hoveredMessageId === messageKey ? 1 : 0,
opacity: isHovered ? 1 : 0,
scale: 1,
}}
className="h-3 w-3 text-gray-400 hover:text-neutral-600 transition-colors"
onClick={() => {
if (playingMessageId === messageKey) {
if (isPlaying) {
stop();
setPlayingMessageId(null);
} else {
stop();
setLocalHighlightSegment(null);
localHighlightQueueRef.current = [];
setSpeechLoadingId(null);
const text = displayContent.trim();
const text = trimmedDisplayContent;
if (text) {
const chunks: string[] = [];
const lines = text.split(/\r?\n/);
Expand All @@ -644,10 +662,7 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
if (chunks.length > 0) {
let charCursor = 0;
const segments = chunks.map((chunk) => {
const visibleLen = segmentChatMarkdownText(chunk).reduce(
(acc, token) => acc + token.content.length,
0
);
const visibleLen = measureVisibleLength(chunk);
const seg = {
messageId: message.id || messageKey,
start: charCursor,
Expand Down Expand Up @@ -685,13 +700,13 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
}
}}
aria-label={
playingMessageId === messageKey
isPlaying
? t("apps.chats.ariaLabels.stopSpeech")
: t("apps.chats.ariaLabels.speakMessage")
}
>
{playingMessageId === messageKey ? (
speechLoadingId === messageKey ? (
{isPlaying ? (
isSpeechLoading ? (
<ActivityIndicator size="xs" />
) : (
<Pause className="h-3 w-3" weight="bold" />
Expand All @@ -713,7 +728,7 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: hoveredMessageId === messageKey ? 1 : 0,
opacity: isHovered ? 1 : 0,
scale: 1,
}}
className="h-3 w-3 text-gray-400 hover:text-blue-600 transition-colors"
Expand Down Expand Up @@ -742,7 +757,7 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: hoveredMessageId === messageKey ? 1 : 0,
opacity: isHovered ? 1 : 0,
scale: 1,
}}
className="h-3 w-3 text-gray-400 hover:text-red-600 transition-colors"
Expand Down Expand Up @@ -883,14 +898,16 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
: partText;
const partDisplayContent = decodeHtmlEntities(rawPartContent);
const textContent = partDisplayContent;
const partTokens = textContent
? segmentChatMarkdownText(textContent.trim())
: [];
return (
<div key={partKey} className="w-full">
<div className="whitespace-pre-wrap">
{textContent &&
(() => {
const tokens = segmentChatMarkdownText(textContent.trim());
let charPos = 0;
return tokens.map((segment, idx) => {
return partTokens.map((segment, idx) => {
const start = charPos;
const end = charPos + segment.content.length;
charPos = end;
Expand Down Expand Up @@ -989,9 +1006,9 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
}}
>
{(() => {
const tokens = segmentChatMarkdownText(displayContent);
const tokens = messageTokens;
let charPos2 = 0;
return tokens.map((segment, idx) => {
return tokens.map((segment: ChatMarkdownToken, idx: number) => {
const start2 = charPos2;
const end2 = charPos2 + segment.content.length;
charPos2 = end2;
Expand Down Expand Up @@ -1029,47 +1046,45 @@ const ChatMessageItem = memo(function ChatMessageItem(props: ChatMessageItemProp
</motion.div>
)}

{(() => {
const allUrls = new Set<string>();
if (message.role === "assistant") {
message.parts?.forEach(
(
part: ToolInvocationPart | { type: string; text?: string }
) => {
if (part.type === "text") {
const partText =
(part as { type: string; text?: string }).text || "";
const partContent = isUrgentMessage(partText)
? partText.slice(4).trimStart()
: partText;
extractUrls(decodeHtmlEntities(partContent)).forEach((u) =>
allUrls.add(u)
);
}
}
);
} else {
extractUrls(displayContent).forEach((u) => allUrls.add(u));
}
if (allUrls.size === 0) return null;
return (
<div
className={`flex flex-col gap-2 w-full ${
!isUrlOnly(displayContent) ? "mt-2" : ""
} ${message.role === "user" ? "items-end" : "items-start"}`}
>
{Array.from(allUrls).map((url, index) => (
<LinkPreview
key={`${messageKey}-link-${index}`}
url={url}
className="max-w-[90%]"
/>
))}
</div>
);
})()}
{messageUrls.length > 0 && (
<div
className={`flex flex-col gap-2 w-full ${
!isUrlOnly(displayContent) ? "mt-2" : ""
} ${message.role === "user" ? "items-end" : "items-start"}`}
>
{messageUrls.map((url: string, index: number) => (
<LinkPreview
key={`${messageKey}-link-${index}`}
url={url}
className="max-w-[90%]"
/>
))}
</div>
)}
</motion.div>
);
}, (prev, next) => {
if (prev.message !== next.message) return false;
if (prev.messageKey !== next.messageKey) return false;
if (prev.isInitialMessage !== next.isInitialMessage) return false;
if (prev.isLoading !== next.isLoading) return false;
if (prev.isLoadingGreeting !== next.isLoadingGreeting) return false;
if (prev.isRoomView !== next.isRoomView) return false;
if (prev.fontSize !== next.fontSize) return false;
if (prev.currentTheme !== next.currentTheme) return false;
if (prev.isCopied !== next.isCopied) return false;
if (prev.isHovered !== next.isHovered) return false;
if (prev.isPlaying !== next.isPlaying) return false;
if (prev.isSpeechLoading !== next.isSpeechLoading) return false;
if (prev.isSpeaking !== next.isSpeaking) return false;
if (prev.localTtsSpeaking !== next.localTtsSpeaking) return false;
if (prev.speechEnabled !== next.speechEnabled) return false;
if (prev.isAdmin !== next.isAdmin) return false;
if (prev.roomId !== next.roomId) return false;
if (prev.username !== next.username) return false;
if (prev.highlightSegment !== next.highlightSegment) return false;
if (prev.localHighlightSegment !== next.localHighlightSegment) return false;
return true;
});

// --- NEW INNER COMPONENT ---
Expand Down Expand Up @@ -1307,10 +1322,23 @@ function ChatMessagesContent({
)}
{messages.map((message) => {
const messageText = getMessageText(message);
const messageKey = (message.id === "1" || message.id === "proactive-1")
? "greeting"
: message.id || `${message.role}-${messageText.substring(0, 10)}`;
const messageKey =
message.id === "1" || message.id === "proactive-1"
? "greeting"
: message.id || `${message.role}-${messageText.substring(0, 10)}`;
const isInitialMessage = initialMessageIdsRef.current.has(messageKey);
const matchesHighlight =
!!highlightSegment &&
(highlightSegment.messageId === message.id ||
highlightSegment.messageId === messageKey);
const matchesLocalHighlight =
!!localHighlightSegment &&
(localHighlightSegment.messageId === message.id ||
localHighlightSegment.messageId === messageKey);
const messageHighlightSegment = matchesHighlight ? highlightSegment : null;
const messageLocalHighlightSegment = matchesLocalHighlight
? localHighlightSegment
: null;
return (
<ChatMessageItem
key={messageKey}
Expand All @@ -1322,14 +1350,16 @@ function ChatMessagesContent({
isRoomView={isRoomView}
fontSize={fontSize}
currentTheme={currentTheme}
copiedMessageId={copiedMessageId}
hoveredMessageId={hoveredMessageId}
playingMessageId={playingMessageId}
speechLoadingId={speechLoadingId}
highlightSegment={highlightSegment ?? null}
localHighlightSegment={localHighlightSegment}
isSpeaking={!!isSpeaking}
localTtsSpeaking={localTtsSpeaking}
isCopied={
copiedMessageId === messageKey || copiedMessageId === message.id
}
isHovered={hoveredMessageId === messageKey}
isPlaying={playingMessageId === messageKey}
isSpeechLoading={speechLoadingId === messageKey}
highlightSegment={messageHighlightSegment}
localHighlightSegment={messageLocalHighlightSegment}
isSpeaking={!!isSpeaking && matchesHighlight}
localTtsSpeaking={localTtsSpeaking && matchesLocalHighlight}
speechEnabled={speechEnabled}
isAdmin={isAdmin}
roomId={roomId}
Expand Down
Loading
Loading