feat: Show Message Timestamps on Hover

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
This commit is contained in:
Marco Beretta 2026-06-12 02:16:45 +02:00
parent b39ec16ff0
commit a4ae5b7e5e
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 198 additions and 1 deletions

View file

@ -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({
<h2 className={cn('select-none font-semibold', fontSize)}>
<span className="sr-only">{getHeaderPrefixForScreenReader(msg, localize)}</span>
{messageLabel}
<MessageTimestamp value={msg.createdAt ?? msg.clientTimestamp} />
</h2>
)}

View file

@ -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 (
<time
dateTime={timestamp.iso}
title={timestamp.isRecent ? timestamp.absolute : undefined}
className="ml-2 text-xs font-normal text-text-secondary transition-opacity duration-200 md:opacity-0 md:group-focus-within:opacity-100 md:group-hover:opacity-100"
>
{timestamp.isRecent ? timestamp.relative : timestamp.absolute}
</time>
);
}

View file

@ -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({
<h2 className={cn('select-none font-semibold', fontSize)}>
<span className="sr-only">{getHeaderPrefixForScreenReader(msg, localize)}</span>
{messageLabel}
<MessageTimestamp value={msg.createdAt ?? msg.clientTimestamp} />
</h2>
)}

View file

@ -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<string, string> = {
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();
});
});

View file

@ -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 `<time>` element's `dateTime` attribute. */
iso: string;
/**
* True when the message is recent enough that the relative form ("10 minutes ago")
* reads better than the absolute date. Past this window the absolute date is clearer.
*/
isRecent: boolean;
};
/** Below this age the relative form is preferred over the absolute date. */
const RECENT_THRESHOLD_MS = 24 * 60 * 60 * 1000;
/** Returns true when `value` parses to a valid date. */
export const isValidTimestamp = (value?: string | null): value is string => {
if (!value) {
return false;
}
return !Number.isNaN(new Date(value).getTime());
};
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
{ amount: 60, unit: 'second' },
{ amount: 60, unit: 'minute' },
{ amount: 24, unit: 'hour' },
{ amount: 7, unit: 'day' },
{ amount: 4.34524, unit: 'week' },
{ amount: 12, unit: 'month' },
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
];
/** Returns the locale only when it is a syntactically valid BCP-47 tag, else undefined. */
const resolveLocale = (locale?: string): string | undefined => {
if (!locale) {
return undefined;
}
try {
Intl.DateTimeFormat.supportedLocalesOf(locale);
return locale;
} catch {
return undefined;
}
};
const formatRelativeTime = (from: Date, to: Date, locale?: string): string => {
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
let duration = (from.getTime() - to.getTime()) / 1000;
for (const division of RELATIVE_TIME_DIVISIONS) {
if (Math.abs(duration) < division.amount) {
return formatter.format(Math.round(duration), division.unit);
}
duration /= division.amount;
}
return formatter.format(Math.round(duration), 'year');
};
/**
* Formats a message timestamp into locale-aware relative and absolute strings.
* Returns null when the value is missing or unparseable, so callers can skip
* rendering the timestamp entirely.
*/
export const getMessageTimestamp = (
value?: string | null,
locale?: string,
): MessageTimestamp | null => {
if (!isValidTimestamp(value)) {
return null;
}
const date = new Date(value);
const now = new Date(Date.now());
const safeLocale = resolveLocale(locale);
return {
iso: date.toISOString(),
relative: formatRelativeTime(date, now, safeLocale),
absolute: new Intl.DateTimeFormat(safeLocale, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date),
isRecent: Math.abs(now.getTime() - date.getTime()) < RECENT_THRESHOLD_MS,
};
};
/**
* Creates initial content parts for dual message display with agent-based grouping.
* Sets up primary and added agent content parts with agentId for column rendering.