From 69764144649d3a7011f6f9a376088ecc4709a06c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 20 Mar 2026 16:50:12 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=A3=EF=B8=8F=20a11y:=20Distinguish=20C?= =?UTF-8?q?onversation=20Headings=20for=20Screen=20Readers=20(#12341)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: distinguish message headings for screen readers Before, each message would have the heading of either the name of the user or the name of the agent (e.g. "Dan Lew" or "Claude Sonnet"). If you tried to navigate that with a screen reader, you'd just see a ton of headings switching back and forth between the two with no way to figure out where in the conversation each is. Now, we prefix each header with whether it's a "prompt" or "response", plus we number them so that you can distinguish how far in the conversation each part is. (This is a screen reader only change - there's no visual difference.) * fix: patch MessageParts heading, guard negative depth, add tests - Add sr-only heading prefix to MessageParts.tsx (Assistants endpoint path) - Extract shared getMessageNumber helper to avoid DRY violation between getMessageAriaLabel and getHeaderPrefixForScreenReader - Guard against depth < 0 producing "Prompt 0:" / "Response 0:" - Remove unused lodash import - Add unit tests covering all branches including depth edge cases --------- Co-authored-by: Dan Lew --- .../components/Chat/Messages/MessageParts.tsx | 5 +- .../Chat/Messages/ui/MessageRender.tsx | 9 +- .../src/components/Messages/ContentRender.tsx | 7 +- client/src/utils/__tests__/messages.test.ts | 82 +++++++++++++++++++ client/src/utils/messages.ts | 29 ++++++- 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 client/src/utils/__tests__/messages.test.ts 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.