This commit is contained in:
Jack Peplinski 2025-09-19 17:15:26 -04:00 committed by GitHub
commit 6e08f72830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 183 additions and 12 deletions

View file

@ -92,11 +92,11 @@ describe('Conversation Structure Tests', () => {
conversationId,
user: userId,
text: `Message ${i}`,
createdAt: new Date(Date.now() + (i % 2 === 0 ? i * 500000 : -i * 500000)),
createdAt: new Date(Date.now() + i * 1000),
}));
// Save messages with new timestamps being generated (message objects ignored)
await bulkSaveMessages(messages);
await bulkSaveMessages(messages, true);
// Retrieve messages (this will sort by createdAt, but it shouldn't matter now)
const retrievedMessages = await getMessages({ conversationId, user: userId });

View file

@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
@ -11,8 +11,10 @@ import HoverButtons from './HoverButtons';
import SubRow from './SubRow';
import { cn } from '~/utils';
import store from '~/store';
import Timestamp from './Timestamp';
export default function Message(props: TMessageProps) {
const [isHovered, setIsHovered] = useState(false);
const localize = useLocalize();
const { message, siblingIdx, siblingCount, setSiblingIdx, currentEditId, setCurrentEditId } =
props;
@ -95,7 +97,9 @@ export default function Message(props: TMessageProps) {
<div
id={messageId ?? ''}
aria-label={`message-${message.depth}-${messageId}`}
className={cn(baseClasses.common, baseClasses.chat, 'message-render')}
className={cn(baseClasses.common, baseClasses.chat, 'message-render', 'relative')}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative flex flex-shrink-0 flex-col items-center">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full pt-0.5">
@ -110,6 +114,7 @@ export default function Message(props: TMessageProps) {
>
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
{name}
<Timestamp message={message} isVisible={isHovered} />
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View file

@ -0,0 +1,48 @@
import React, { memo } from 'react';
import { cn } from '~/utils';
import { formatTimestamp } from '~/utils/dateFormatter';
import { type TMessage } from 'librechat-data-provider';
import { useMessageTimestamp } from '~/hooks/Messages';
interface TimestampProps {
message: TMessage;
isVisible: boolean;
}
const Timestamp = memo(
({ message, isVisible }: TimestampProps) => {
try {
const timestamp = useMessageTimestamp(message.messageId, message);
const displayTimestamp = timestamp || message.clientTimestamp;
if (!displayTimestamp) {
return null;
}
const formattedTime = formatTimestamp(displayTimestamp);
return (
<span
className={cn(
'ml-2 text-xs font-normal text-text-secondary-alt transition-opacity duration-300',
isVisible ? 'opacity-100' : 'opacity-0',
'inline-block',
)}
>
{formattedTime}
</span>
);
} catch (error) {
console.error('Failed to render timestamp:', error);
return null;
}
},
(prevProps, nextProps) => {
return (
prevProps.message.messageId === nextProps.message.messageId &&
prevProps.isVisible === nextProps.isVisible
);
},
);
export default Timestamp;

View file

@ -1,4 +1,4 @@
import React, { useCallback, useMemo, memo } from 'react';
import React, { useCallback, useMemo, memo, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { type TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
@ -13,6 +13,7 @@ import { MessageContext } from '~/Providers';
import { useMessageActions } from '~/hooks';
import { cn, logger } from '~/utils';
import store from '~/store';
import Timestamp from '../Timestamp';
type MessageRenderProps = {
message?: TMessage;
@ -36,6 +37,7 @@ const MessageRender = memo(
setCurrentEditId,
isSubmittingFamily = false,
}: MessageRenderProps) => {
const [isHovered, setIsHovered] = useState(false);
const {
ask,
edit,
@ -141,6 +143,8 @@ const MessageRender = memo(
clickHandler();
}
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role={showCardRender ? 'button' : undefined}
tabIndex={showCardRender ? 0 : undefined}
>
@ -160,7 +164,10 @@ const MessageRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
<Timestamp message={msg} isVisible={isHovered} />
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View file

@ -1,5 +1,5 @@
import { useRecoilValue } from 'recoil';
import { useCallback, useMemo, memo } from 'react';
import { useCallback, useMemo, memo, useState } from 'react';
import type { TMessage, TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
@ -11,6 +11,7 @@ import { useAttachments, useMessageActions } from '~/hooks';
import SubRow from '~/components/Chat/Messages/SubRow';
import { cn, logger } from '~/utils';
import store from '~/store';
import Timestamp from '../Chat/Messages/Timestamp';
type ContentRenderProps = {
message?: TMessage;
@ -34,6 +35,7 @@ const ContentRender = memo(
setCurrentEditId,
isSubmittingFamily = false,
}: ContentRenderProps) => {
const [isHovered, setIsHovered] = useState(false);
const { attachments, searchResults } = useAttachments({
messageId: msg?.messageId,
attachments: msg?.attachments,
@ -135,6 +137,8 @@ const ContentRender = memo(
'message-render',
)}
onClick={clickHandler}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && clickHandler) {
clickHandler();
@ -159,7 +163,10 @@ const ContentRender = memo(
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
)}
>
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
<Timestamp message={msg} isVisible={isHovered} />
</h2>
<div className="flex flex-col gap-1">
<div className="flex max-w-full flex-grow flex-col gap-0">

View file

@ -6,3 +6,4 @@ export { default as useMessageProcess } from './useMessageProcess';
export { default as useMessageHelpers } from './useMessageHelpers';
export { default as useCopyToClipboard } from './useCopyToClipboard';
export { default as useMessageScrolling } from './useMessageScrolling';
export { useMessageTimestamp } from './useMessageTimestamp';

View file

@ -0,0 +1,22 @@
import { useRecoilValue } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import store from '~/store';
/**
* Hook to get the timestamp for a message.
* Returns the message's createdAt field if it exists (backend data takes priority),
* otherwise returns the locked timestamp from Recoil.
* This ensures that once a timestamp is set for a message during streaming,
* it won't change even if the component rerenders frequently.
*/
export const useMessageTimestamp = (messageId: string, message?: TMessage): string | null => {
const lockedTimestamp = useRecoilValue(store.messageTimestampState(messageId));
// Backend data takes priority - use createdAt if available
if (message?.createdAt) {
return message.createdAt;
}
// Otherwise return the locked timestamp from Recoil
return lockedTimestamp;
};

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { v4 } from 'uuid';
import { SSE } from 'sse.js';
import { useSetRecoilState } from 'recoil';
import { useSetRecoilState, useRecoilCallback } from 'recoil';
import {
request,
Constants,
@ -47,6 +47,18 @@ export default function useSSE(
const genTitle = useGenTitleMutation();
const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex));
const lockMessageTimestamp = useRecoilCallback(
({ snapshot, set }) =>
async (messageId: string) => {
const currentTimestamp = await snapshot.getPromise(store.messageTimestampState(messageId));
if (currentTimestamp === null) {
// Only set timestamp if it hasn't been set already (lock on first write)
set(store.messageTimestampState(messageId), new Date().toISOString());
}
},
[],
);
const { token, isAuthenticated } = useAuthContext();
const [completed, setCompleted] = useState(new Set());
const setAbortScroll = useSetRecoilState(store.abortScrollFamily(runIndex));
@ -120,13 +132,17 @@ export default function useSSE(
sse.addEventListener('message', (e: MessageEvent) => {
const data = JSON.parse(e.data);
if (data.final != null) {
clearDraft(submission.conversation?.conversationId);
const { plugins } = data;
finalHandler(data, { ...submission, plugins } as EventSubmission);
(startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch();
console.log('final', data);
// Lock the timestamp for the response message when stream finishes
if (data.responseMessage?.messageId) {
lockMessageTimestamp(data.responseMessage.messageId);
}
return;
} else if (data.created != null) {
const runId = v4();
@ -137,6 +153,11 @@ export default function useSSE(
overrideParentMessageId: userMessage.overrideParentMessageId,
};
// Lock the timestamp for the user message when it's created
if (userMessage.messageId) {
lockMessageTimestamp(userMessage.messageId);
}
createdHandler(data, { ...submission, userMessage } as EventSubmission);
} else if (data.event != null) {
stepHandler(data, { ...submission, userMessage } as EventSubmission);
@ -170,7 +191,6 @@ export default function useSSE(
sse.addEventListener('open', () => {
setAbortScroll(false);
console.log('connection is opened');
});
sse.addEventListener('cancel', async () => {

View file

@ -255,6 +255,11 @@ const messagesSiblingIdxFamily = atomFamily<number, string | null | undefined>({
default: 0,
});
const messageTimestampState = atomFamily<string | null, string>({
key: 'messageTimestampState',
default: null,
});
function useCreateConversationAtom(key: string | number) {
const hasSetConversation = useSetConvoContext();
const [keys, setKeys] = useRecoilState(conversationKeysAtom);
@ -399,6 +404,7 @@ export default {
showPopoverFamily,
latestMessageFamily,
messagesSiblingIdxFamily,
messageTimestampState,
allConversationsSelector,
conversationByKeySelector,
useClearConvoState,

View file

@ -0,0 +1,55 @@
/**
* Efficient date formatter using cached Intl.DateTimeFormat
* Avoids repeated locale database lookups that occur with toLocaleString()
*/
class DateFormatter {
private static instance: DateFormatter;
private formatter: Intl.DateTimeFormat;
private constructor() {
// Create a single formatter instance with desired options
this.formatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
public static getInstance(): DateFormatter {
if (!DateFormatter.instance) {
DateFormatter.instance = new DateFormatter();
}
return DateFormatter.instance;
}
public format(date: Date): string {
return this.formatter.format(date);
}
public formatTimestamp(timestamp: string | null | undefined): string {
if (!timestamp) {
return '';
}
try {
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
return timestamp;
}
return this.format(date);
} catch (error) {
console.error('Failed to format timestamp:', error);
return timestamp;
}
}
}
// Export convenience function
export const formatTimestamp = (timestamp: string | null | undefined): string => {
return DateFormatter.getInstance().formatTimestamp(timestamp);
};
export default DateFormatter;