feat: Show Message Timestamps on Hover (#13709)

*  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

* 🎨 fix: Gate message timestamp reveal on hover capability, not width

Use a (hover: hover) media query instead of the md: breakpoint so the timestamp stays visible on touch and other non-hover devices (e.g. tablets wider than md), while still revealing on hover/focus where a pointer supports it.

* 🎨 fix: Show message timestamps across all renderers and keep them live

Extend the hover timestamp to the Assistants (MessageParts), shared-link, search, and parallel/multi-response renderers so every prompt and response shows when it was sent. The parent message's createdAt is threaded down to parallel column headers (SiblingHeader).

Add a shared, ref-counted minute ticker (useTimeTick) so relative labels like "2 minutes ago" stay current while a conversation is left open instead of freezing at first render.
This commit is contained in:
Marco Beretta
2026-06-14 14:39:52 +01:00
committed by GitHub
parent 9618be6eb3
commit fc2ae89aa6
12 changed files with 264 additions and 8 deletions

View File

@@ -6,14 +6,14 @@ import type {
TAttachment,
Agents,
} from 'librechat-data-provider';
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
import { mapAttachments, groupSequentialToolCalls } from '~/utils';
import { MessageContext, SearchContext } from '~/Providers';
import { EditTextPart, EmptyText } from './Parts';
import PendingSkillCall from './Parts/PendingSkillCall';
import { EditTextPart, EmptyText } from './Parts';
import MemoryArtifacts from './MemoryArtifacts';
import ToolCallGroup from './ToolCallGroup';
import type { ToolCallGroupExpansionState } from './ToolCallGroup';
import Container from './Container';
import Part from './Part';
@@ -105,6 +105,8 @@ type ContentPartsProps = {
* the full message object) so `React.memo` stays shallow-happy.
*/
manualSkills?: string[];
/** ISO timestamp of the parent message, surfaced in parallel column headers. */
createdAt?: string | null;
conversationId?: string | null;
attachments?: TAttachment[];
searchResults?: { [key: string]: SearchResultData };
@@ -142,6 +144,7 @@ const ContentParts = memo(function ContentParts({
conversationId,
isCreatedByUser,
isLatestMessage,
createdAt,
}: ContentPartsProps) {
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
@@ -375,6 +378,7 @@ const ContentParts = memo(function ContentParts({
<ParallelContentRenderer
content={content}
messageId={messageId}
createdAt={createdAt}
conversationId={conversationId}
attachments={attachments}
searchResults={searchResults}

View File

@@ -1,10 +1,10 @@
import { memo, useMemo } from 'react';
import type { TMessageContentParts, SearchResultData, TAttachment } from 'librechat-data-provider';
import { SearchContext } from '~/Providers';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
import { EmptyText } from './Parts';
import { SearchContext } from '~/Providers';
import SiblingHeader from './SiblingHeader';
import { EmptyText } from './Parts';
import Container from './Container';
import { cn } from '~/utils';
@@ -137,6 +137,7 @@ type ParallelColumnsProps = {
columns: ParallelColumn[];
groupId: number;
messageId: string;
createdAt?: string | null;
isSubmitting: boolean;
lastContentIdx: number;
conversationId?: string | null;
@@ -150,6 +151,7 @@ export const ParallelColumns = memo(function ParallelColumns({
columns,
groupId,
messageId,
createdAt,
conversationId,
isSubmitting,
lastContentIdx,
@@ -169,6 +171,7 @@ export const ParallelColumns = memo(function ParallelColumns({
<SiblingHeader
agentId={agentId}
messageId={messageId}
createdAt={createdAt}
isSubmitting={isSubmitting}
conversationId={conversationId}
/>
@@ -193,6 +196,7 @@ export const ParallelColumns = memo(function ParallelColumns({
type ParallelContentRendererProps = {
content?: Array<TMessageContentParts | undefined>;
messageId: string;
createdAt?: string | null;
conversationId?: string | null;
attachments?: TAttachment[];
searchResults?: { [key: string]: SearchResultData };
@@ -207,6 +211,7 @@ type ParallelContentRendererProps = {
export const ParallelContentRenderer = memo(function ParallelContentRenderer({
content,
messageId,
createdAt,
conversationId,
attachments,
searchResults,
@@ -253,6 +258,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({
columns={columns}
groupId={groupId}
messageId={messageId}
createdAt={createdAt}
renderPart={renderPart}
isSubmitting={isSubmitting}
conversationId={conversationId}

View File

@@ -3,6 +3,7 @@ import { GitBranchPlus } from 'lucide-react';
import { useToastContext } from '@librechat/client';
import { EModelEndpoint, parseEphemeralAgentId, stripAgentIdSuffix } from 'librechat-data-provider';
import type { TMessage, Agent } from 'librechat-data-provider';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import { useBranchMessageMutation } from '~/data-provider/Messages';
import MessageIcon from '~/components/Share/MessageIcon';
import { useAgentsMapContext } from '~/Providers';
@@ -14,6 +15,8 @@ type SiblingHeaderProps = {
agentId?: string;
/** The messageId of the parent message */
messageId?: string;
/** ISO timestamp of the parent message */
createdAt?: string | null;
/** The conversationId */
conversationId?: string | null;
/** Whether a submission is in progress */
@@ -27,6 +30,7 @@ type SiblingHeaderProps = {
export default function SiblingHeader({
agentId,
messageId,
createdAt,
conversationId,
isSubmitting,
}: SiblingHeaderProps) {
@@ -117,6 +121,7 @@ export default function SiblingHeader({
/>
</div>
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
<MessageTimestamp value={createdAt} />
</div>
<button
type="button"

View File

@@ -5,6 +5,7 @@ import type { TMessageContentParts } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
import { useMessageHelpers, useLocalize, useAttachments, useContentMetadata } from '~/hooks';
import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import ContentParts from './Content/ContentParts';
import { fontSizeAtom } from '~/store/fontSize';
@@ -134,6 +135,7 @@ export default function Message(props: TMessageProps) {
{getHeaderPrefixForScreenReader(message, localize)}
</span>
{name}
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
</h2>
)}
<div className="flex flex-col gap-1">

View File

@@ -1,10 +1,11 @@
import { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
import { useAuthContext, useLocalize } from '~/hooks';
import type { TMessageProps, TMessageIcon } from '~/common';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { useAuthContext, useLocalize } from '~/hooks';
import SearchContent from './Content/SearchContent';
import { fontSizeAtom } from '~/store/fontSize';
import SearchButtons from './SearchButtons';
@@ -26,7 +27,10 @@ const MessageBody = ({ message, messageLabel, fontSize }) => (
<div
className={cn('relative flex w-11/12 flex-col', message.isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<div className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
</div>
<SearchContent message={message} />
<SubRow classes="text-xs">
<MinimalHoverButtons message={message} />

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 { useTranslation } from 'react-i18next';
import useTimeTick from '~/hooks/useTimeTick';
import { getMessageTimestamp } from '~/utils';
/**
* Inline message timestamp shown next to the author name in the message header.
* On hover-capable pointers it reveals on row hover/focus; on touch and other
* non-hover devices it stays visible. 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();
// Re-render on a shared interval so relative labels stay current while idle.
useTimeTick();
const timestamp = getMessageTimestamp(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 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:hover)]:opacity-0"
>
{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>
)}
@@ -223,6 +226,7 @@ const ContentRender = memo(function ContentRender({
isLatestMessage={isLatestMessage}
isSubmitting={isSubmitting}
isCreatedByUser={msg.isCreatedByUser}
createdAt={msg.createdAt ?? msg.clientTimestamp}
conversationId={conversation?.conversationId}
content={msg.content as Array<TMessageContentParts | undefined>}
/>

View File

@@ -2,6 +2,7 @@ import { useAtomValue } from 'jotai';
import type { TMessageProps } from '~/common';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import SearchContent from '~/components/Chat/Messages/Content/SearchContent';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
import SubRow from '~/components/Chat/Messages/SubRow';
@@ -65,7 +66,10 @@ export default function Message(props: TMessageProps) {
<div
className={cn('relative flex w-11/12 flex-col', isCreatedByUser ? '' : 'agent-turn')}
>
<div className={cn('select-none font-semibold', fontSize)}>{messageLabel}</div>
<div className={cn('select-none font-semibold', fontSize)}>
{messageLabel}
<MessageTimestamp value={message.createdAt ?? message.clientTimestamp} />
</div>
<div className="flex-col gap-1 md:gap-3">
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
<MessageContext.Provider

View File

@@ -0,0 +1,33 @@
import { useSyncExternalStore } from 'react';
const listeners = new Set<() => void>();
let tick = 0;
let intervalId: ReturnType<typeof setInterval> | null = null;
const subscribe = (onStoreChange: () => void): (() => void) => {
listeners.add(onStoreChange);
if (intervalId === null) {
intervalId = setInterval(() => {
tick += 1;
listeners.forEach((listener) => listener());
}, 60_000);
}
return () => {
listeners.delete(onStoreChange);
if (listeners.size === 0 && intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
};
const getSnapshot = (): number => tick;
/**
* Subscribes to a shared, ref-counted ticker that fires once a minute, so components
* displaying relative time stay current while a view is left open. A single interval
* is shared across all subscribers and is cleared when the last one unsubscribes.
*/
export default function useTimeTick(): number {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

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.