🗣️ a11y: Distinguish Conversation Headings for Screen Readers (#12341)

* 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 <daniel@mightyacorn.com>
This commit is contained in:
Danny Avila 2026-03-20 16:50:12 -04:00 committed by GitHub
parent 729ba96100
commit 6976414464
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 123 additions and 9 deletions

View file

@ -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 && (
<h2 className={cn('select-none font-semibold text-text-primary', fontSize)}>
<span className="sr-only">
{getHeaderPrefixForScreenReader(message, localize)}
</span>
{name}
</h2>
)}

View file

@ -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 && (
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
<h2 className={cn('select-none font-semibold', fontSize)}>
<span className="sr-only">{getHeaderPrefixForScreenReader(msg, localize)}</span>
{messageLabel}
</h2>
)}
<div className="flex flex-col gap-1">