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 `