diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 7aa73a54e6..3d13fa6ae0 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -4,6 +4,7 @@ import { useRecoilValue } from 'recoil'; 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 MessageIcon from '~/components/Chat/Messages/MessageIcon'; import ContentParts from './Content/ContentParts'; import { fontSizeAtom } from '~/store/fontSize'; @@ -11,7 +12,6 @@ import SiblingSwitch from './SiblingSwitch'; import MultiMessage from './MultiMessage'; import HoverButtons from './HoverButtons'; import SubRow from './SubRow'; -import { cn, getMessageAriaLabel } from '~/utils'; import store from '~/store'; export default function Message(props: TMessageProps) { @@ -125,6 +125,9 @@ export default function Message(props: TMessageProps) { > {!hasParallelContent && (

+ + {getHeaderPrefixForScreenReader(message, localize)} + {name}

)} diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index e261a576bd..93586f0d2f 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useMemo, memo } from 'react'; import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; -import { type TMessage } from 'librechat-data-provider'; +import type { TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; +import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; import MessageContent from '~/components/Chat/Messages/Content/MessageContent'; import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; @@ -10,7 +11,6 @@ import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import SubRow from '~/components/Chat/Messages/SubRow'; -import { cn, getMessageAriaLabel } from '~/utils'; import { fontSizeAtom } from '~/store/fontSize'; import { MessageContext } from '~/Providers'; import store from '~/store'; @@ -148,7 +148,10 @@ const MessageRender = memo(function MessageRender({ )} > {!hasParallelContent && ( -

{messageLabel}

+

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

)}
diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 4114baefe4..6b3f05ce5d 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -4,13 +4,13 @@ import { useRecoilValue } from 'recoil'; import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import { useAttachments, useLocalize, useMessageActions, useContentMetadata } from '~/hooks'; +import { cn, getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow'; import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch'; import HoverButtons from '~/components/Chat/Messages/HoverButtons'; import MessageIcon from '~/components/Chat/Messages/MessageIcon'; import SubRow from '~/components/Chat/Messages/SubRow'; -import { cn, getMessageAriaLabel } from '~/utils'; import { fontSizeAtom } from '~/store/fontSize'; import store from '~/store'; @@ -140,7 +140,10 @@ const ContentRender = memo(function ContentRender({ )} > {!hasParallelContent && ( -

{messageLabel}

+

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

)}
diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts new file mode 100644 index 0000000000..4af9f69439 --- /dev/null +++ b/client/src/utils/__tests__/messages.test.ts @@ -0,0 +1,82 @@ +import type { TMessage } from 'librechat-data-provider'; +import type { LocalizeFunction } from '~/common'; +import { getMessageAriaLabel, getHeaderPrefixForScreenReader } from '../messages'; + +const translations: Record = { + com_endpoint_message: 'Message', + com_endpoint_message_new: 'Message {{0}}', + com_ui_prompt: 'Prompt', + com_ui_response: 'Response', +}; + +const localize: LocalizeFunction = ((key: string, args?: Record) => { + const template = translations[key] ?? key; + if (args) { + return Object.entries(args).reduce( + (result, [k, v]) => result.replace(`{{${k}}}`, String(v)), + template, + ); + } + return template; +}) as LocalizeFunction; + +const makeMessage = (overrides: Partial = {}): TMessage => + ({ + messageId: 'msg-1', + isCreatedByUser: false, + ...overrides, + }) as TMessage; + +describe('getMessageAriaLabel', () => { + it('returns "Message N" when depth is present and valid', () => { + const msg = makeMessage({ depth: 2 }); + expect(getMessageAriaLabel(msg, localize)).toBe('Message 3'); + }); + + it('returns "Message" when depth is undefined', () => { + const msg = makeMessage({ depth: undefined }); + expect(getMessageAriaLabel(msg, localize)).toBe('Message'); + }); + + it('returns "Message" when depth is negative', () => { + const msg = makeMessage({ depth: -1 }); + expect(getMessageAriaLabel(msg, localize)).toBe('Message'); + }); + + it('returns "Message 1" for depth 0 (root message)', () => { + const msg = makeMessage({ depth: 0 }); + expect(getMessageAriaLabel(msg, localize)).toBe('Message 1'); + }); +}); + +describe('getHeaderPrefixForScreenReader', () => { + it('returns "Prompt N: " for user messages with valid depth', () => { + const msg = makeMessage({ isCreatedByUser: true, depth: 2 }); + expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Prompt 3: '); + }); + + it('returns "Response N: " for AI messages with valid depth', () => { + const msg = makeMessage({ isCreatedByUser: false, depth: 0 }); + expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response 1: '); + }); + + it('returns "Prompt: " for user messages without depth', () => { + const msg = makeMessage({ isCreatedByUser: true, depth: undefined }); + expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Prompt: '); + }); + + it('returns "Response: " for AI messages without depth', () => { + const msg = makeMessage({ isCreatedByUser: false, depth: undefined }); + expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response: '); + }); + + it('omits number when depth is -1 (no "Prompt 0:" regression)', () => { + const msg = makeMessage({ isCreatedByUser: true, depth: -1 }); + expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Prompt: '); + }); + + it('omits number when depth is negative', () => { + const msg = makeMessage({ isCreatedByUser: false, depth: -5 }); + expect(getHeaderPrefixForScreenReader(msg, localize)).toBe('Response: '); + }); +}); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 7197b6c2db..27dc063481 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -14,7 +14,6 @@ import type { } from 'librechat-data-provider'; import type { QueryClient } from '@tanstack/react-query'; import type { LocalizeFunction } from '~/common'; -import _ from 'lodash'; export const TEXT_KEY_DIVIDER = '|||'; @@ -185,12 +184,36 @@ export const clearMessagesCache = ( } }; +/** Returns a 1-based message number, or null if depth is absent or invalid. */ +const getMessageNumber = (message: TMessage): number | null => { + if (message.depth == null || message.depth < 0) { + return null; + } + return message.depth + 1; +}; + export const getMessageAriaLabel = (message: TMessage, localize: LocalizeFunction): string => { - return !_.isNil(message.depth) - ? localize('com_endpoint_message_new', { 0: message.depth + 1 }) + const number = getMessageNumber(message); + return number != null + ? localize('com_endpoint_message_new', { 0: number }) : localize('com_endpoint_message'); }; +/** + * Provides a screen-reader-only heading prefix distinguishing prompts from responses, + * with an optional 1-based turn number derived from message depth. + */ +export const getHeaderPrefixForScreenReader = ( + message: TMessage, + localize: LocalizeFunction, +): string => { + const number = getMessageNumber(message); + const suffix = number != null ? ` ${number}` : ''; + return message.isCreatedByUser + ? `${localize('com_ui_prompt')}${suffix}: ` + : `${localize('com_ui_response')}${suffix}: `; +}; + /** * 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.