From a4ae5b7e5eee7af3729e908f0075781c3109e76c Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:16:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Show=20Message=20Timestamps?= =?UTF-8?q?=20on=20Hover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reveal a message's time inline next to the author name on hover. Recent messages (under 24h) show a relative time ("10 minutes ago") with the absolute date on hover; older messages show the absolute date directly. A shared MessageTimestamp component is used by both MessageRender and ContentRender, with createdAt added to their memo comparators so the timestamp appears once it's available. Resolves #5199 --- .../Chat/Messages/ui/MessageRender.tsx | 3 + .../Chat/Messages/ui/MessageTimestamp.tsx | 31 +++++++ .../src/components/Messages/ContentRender.tsx | 3 + client/src/utils/__tests__/messages.test.ts | 74 +++++++++++++++- client/src/utils/messages.ts | 88 +++++++++++++++++++ 5 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 client/src/components/Chat/Messages/ui/MessageTimestamp.tsx diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 2e1705a193..c285d68b27 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -5,6 +5,7 @@ import type { TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common'; import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; +import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; @@ -72,6 +73,7 @@ function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRende prevMsg.text === nextMsg.text && prevMsg.error === nextMsg.error && prevMsg.unfinished === nextMsg.unfinished && + prevMsg.createdAt === nextMsg.createdAt && prevMsg.depth === nextMsg.depth && prevMsg.isCreatedByUser === nextMsg.isCreatedByUser && (prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) && @@ -212,6 +214,7 @@ const MessageRender = memo(function MessageRender({

{getHeaderPrefixForScreenReader(msg, localize)} {messageLabel} +

)} diff --git a/client/src/components/Chat/Messages/ui/MessageTimestamp.tsx b/client/src/components/Chat/Messages/ui/MessageTimestamp.tsx new file mode 100644 index 0000000000..ec4a448530 --- /dev/null +++ b/client/src/components/Chat/Messages/ui/MessageTimestamp.tsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getMessageTimestamp } from '~/utils'; + +/** + * Inline message timestamp shown next to the author name in the message header. + * Reveals on row hover (desktop) and stays visible on touch. Recent messages show + * the relative form ("10 minutes ago") with the absolute date on hover; older + * messages show the absolute date directly. + */ +export default function MessageTimestamp({ value }: { value?: string | null }) { + const { i18n } = useTranslation(); + const timestamp = useMemo( + () => getMessageTimestamp(value, i18n.language), + [value, i18n.language], + ); + + if (!timestamp) { + return null; + } + + return ( + + ); +} diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index bb3a32d74e..4f98ad5ad1 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -5,6 +5,7 @@ import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon, TMessageChatContext } from '~/common'; import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; +import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; @@ -70,6 +71,7 @@ function areContentRenderPropsEqual(prev: ContentRenderProps, next: ContentRende prevMsg.text === nextMsg.text && prevMsg.error === nextMsg.error && prevMsg.unfinished === nextMsg.unfinished && + prevMsg.createdAt === nextMsg.createdAt && prevMsg.depth === nextMsg.depth && prevMsg.isCreatedByUser === nextMsg.isCreatedByUser && (prevMsg.children?.length ?? 0) === (nextMsg.children?.length ?? 0) && @@ -205,6 +207,7 @@ const ContentRender = memo(function ContentRender({

{getHeaderPrefixForScreenReader(msg, localize)} {messageLabel} +

)} diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts index 4af9f69439..b919441348 100644 --- a/client/src/utils/__tests__/messages.test.ts +++ b/client/src/utils/__tests__/messages.test.ts @@ -1,6 +1,11 @@ import type { TMessage } from 'librechat-data-provider'; import type { LocalizeFunction } from '~/common'; -import { getMessageAriaLabel, getHeaderPrefixForScreenReader } from '../messages'; +import { + isValidTimestamp, + getMessageAriaLabel, + getMessageTimestamp, + getHeaderPrefixForScreenReader, +} from '../messages'; const translations: Record = { com_endpoint_message: 'Message', @@ -80,3 +85,70 @@ describe('getHeaderPrefixForScreenReader', () => { expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response: '); }); }); + +describe('isValidTimestamp', () => { + it('returns false for missing values', () => { + expect(isValidTimestamp(undefined)).toBe(false); + expect(isValidTimestamp(null)).toBe(false); + expect(isValidTimestamp('')).toBe(false); + }); + + it('returns false for unparseable strings', () => { + expect(isValidTimestamp('not-a-date')).toBe(false); + }); + + it('returns true for ISO date strings', () => { + expect(isValidTimestamp('2026-06-12T15:42:00.000Z')).toBe(true); + }); +}); + +describe('getMessageTimestamp', () => { + const NOW = new Date('2026-06-12T15:42:00.000Z').getTime(); + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(NOW); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns null for missing or invalid values', () => { + expect(getMessageTimestamp(undefined, 'en-US')).toBeNull(); + expect(getMessageTimestamp(null, 'en-US')).toBeNull(); + expect(getMessageTimestamp('not-a-date', 'en-US')).toBeNull(); + }); + + it('formats relative and absolute time for a recent message', () => { + const twoHoursAgo = new Date(NOW - 2 * 60 * 60 * 1000).toISOString(); + const result = getMessageTimestamp(twoHoursAgo, 'en-US'); + expect(result).not.toBeNull(); + expect(result?.relative).toBe('2 hours ago'); + expect(result?.iso).toBe(twoHoursAgo); + expect(result?.absolute).toContain('2026'); + }); + + it('flags messages under 24h as recent (prefer relative)', () => { + const justUnderADay = new Date(NOW - 23 * 60 * 60 * 1000).toISOString(); + expect(getMessageTimestamp(justUnderADay, 'en-US')?.isRecent).toBe(true); + }); + + it('flags older messages as not recent (prefer absolute date)', () => { + const overADay = new Date(NOW - 25 * 60 * 60 * 1000).toISOString(); + const monthAgo = new Date(NOW - 38 * 24 * 60 * 60 * 1000).toISOString(); + expect(getMessageTimestamp(overADay, 'en-US')?.isRecent).toBe(false); + expect(getMessageTimestamp(monthAgo, 'en-US')?.isRecent).toBe(false); + }); + + it('uses "now" for the current instant', () => { + const result = getMessageTimestamp(new Date(NOW).toISOString(), 'en-US'); + expect(result?.relative).toBe('now'); + expect(result?.isRecent).toBe(true); + }); + + it('falls back to the default locale for a malformed locale tag', () => { + const iso = new Date(NOW - 60 * 1000).toISOString(); + expect(() => getMessageTimestamp(iso, 'not a locale!!')).not.toThrow(); + expect(getMessageTimestamp(iso, 'not a locale!!')).not.toBeNull(); + }); +}); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 91312567ec..0c756e7c37 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -344,6 +344,94 @@ export const getHeaderPrefixForScreenReader = ( : `${localize('com_ui_response')}${suffix}: `; }; +export type MessageTimestamp = { + /** Localized relative time, e.g. "2 hours ago". */ + relative: string; + /** Localized absolute date and time, e.g. "Jun 12, 2026, 3:42 PM". */ + absolute: string; + /** ISO 8601 string for the `