authorgravatar for 55418374+Leos-Khai@users.noreply.github.comKhai Shern, Toh <55418374+Leos-Khai@users.noreply.github.com> 2026-09-13 08:56:20+08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 17:56:20-07:00
log6cdbf76fa4b0509913046abe6105037ded9e52bb
tree9839c7ac1c99e19e73ca8466a1924eff3e33fa59
parent68c2277f500bbbb299396bcdcd0aec60dcb5db9d
signature Signed by PGP key B5690EEEBB952194

feat(web): expose each chat message as a heading for screen readers (#11199)


4 files changed, 78 insertions(+), 2 deletions(-)

apps/web/src/components/ChatMarkdown.test.tsx+22
......@@ -694,6 +694,28 @@ describe("ChatMarkdown artifact-template cards", () => {
694694 });
695695});
696696
697describe("ChatMarkdown heading levels", () => {
698 it("exposes headings below the host heading without changing their tags", () => {
699 const html = renderToStaticMarkup(
700 <ChatMarkdown
701 cwd="/tmp/project"
702 text={"# Top\n\n## Section\n\n###### Fine print"}
703 headingLevelOffset={3}
704 />,
705 );
706
707 expect(html).toContain('<h1 aria-level="4">Top</h1>');
708 expect(html).toContain('<h2 aria-level="5">Section</h2>');
709 expect(html).toContain('<h6 aria-level="6">Fine print</h6>');
710 });
711
712 it("leaves heading levels alone when the markdown is not nested", () => {
713 const html = renderToStaticMarkup(<ChatMarkdown cwd="/tmp/project" text="# Top" />);
714
715 expect(html).toContain("<h1>Top</h1>");
716 });
717});
718
697719describe("shouldUseMarkdownFileBrowserPrimaryAction", () => {
698720 it("uses the browser when it is the only available primary action", () => {
699721 expect(
apps/web/src/components/ChatMarkdown.tsx+37-1
......@@ -71,7 +71,11 @@ import React, {
7171 useState,
7272 type ReactNode,
7373} from "react";
74import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
74import type {
75 Components,
76 ExtraProps as ReactMarkdownExtraProps,
77 Options as ReactMarkdownOptions,
78} from "react-markdown";
7579import ReactMarkdown from "react-markdown";
7680import { toHtml } from "hast-util-to-html";
7781import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
......@@ -216,6 +220,10 @@ interface ChatMarkdownProps {
216220 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
217221 /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */
218222 renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;
223 /** Levels added to each markdown heading in the accessibility tree so the
224 text nests under the heading that introduces it, such as a chat message's
225 author. Rendered tags and their styling are unchanged. */
226 headingLevelOffset?: number | undefined;
219227}
220228
221229export interface ChatMarkdownContextReference {
......@@ -2217,6 +2225,7 @@ function useChatMarkdownState({
22172225 imageBaseDir,
22182226 onImageExpand,
22192227 renderContextReference,
2228 headingLevelOffset = 0,
22202229}: ChatMarkdownProps) {
22212230 const { resolvedTheme } = useTheme();
22222231 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
......@@ -2615,6 +2624,7 @@ function useChatMarkdownState({
26152624 expandMedia,
26162625 fileLinkChip,
26172626 renderContextReference,
2627 headingLevelOffset,
26182628 imageBaseDir,
26192629 inlineCodeFileLinkMetaByText,
26202630 isStreaming,
......@@ -2643,6 +2653,7 @@ function useChatMarkdownState({
26432653 expandMedia,
26442654 fileLinkChip,
26452655 renderContextReference,
2656 headingLevelOffset,
26462657 imageBaseDir,
26472658 inlineCodeFileLinkMetaByText,
26482659 isStreaming,
......@@ -2679,8 +2690,33 @@ const ChatMarkdownRendererContext = React.createContext<
26792690 ReturnType<typeof useChatMarkdownState>["componentState"]
26802691>(null!);
26812692
2693// Screen readers take a heading's level from its tag, which would let a `#` in a
2694// message outrank the heading placed above it. Override only the exposed level:
2695// the tag keeps driving the stylesheet and copy-as-markdown.
2696function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) {
2697 const Tag = `h${level}` as const;
2698 return function MarkdownHeading({
2699 node: _node,
2700 ...props
2701 }: ComponentProps<typeof Tag> & ReactMarkdownExtraProps) {
2702 const { headingLevelOffset } = use(ChatMarkdownRendererContext);
2703 return (
2704 <Tag
2705 {...props}
2706 aria-level={headingLevelOffset > 0 ? Math.min(level + headingLevelOffset, 6) : undefined}
2707 />
2708 );
2709 };
2710}
2711
26822712// Keep component types stable when streaming changes the message state.
26832713const CHAT_MARKDOWN_COMPONENTS = {
2714 h1: markdownHeadingRenderer(1),
2715 h2: markdownHeadingRenderer(2),
2716 h3: markdownHeadingRenderer(3),
2717 h4: markdownHeadingRenderer(4),
2718 h5: markdownHeadingRenderer(5),
2719 h6: markdownHeadingRenderer(6),
26842720 div: function MarkdownDiv({ node, children, ...props }) {
26852721 const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);
26862722 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
apps/web/src/components/chat/MessagesTimeline.tsx+14
......@@ -1455,6 +1455,16 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
14551455 );
14561456}
14571457
1458// Screen readers skim a transcript by heading, so every message announces its
1459// author as one. The thread title in ChatHeader is an <h2>; headings written
1460// inside a message are exposed below this level. Visually hidden and excluded
1461// from selection so sighted users and copied text are unaffected.
1462const MESSAGE_HEADING_LEVEL = 3;
1463
1464function MessageAuthorHeading({ children }: { children: string }) {
1465 return <h3 className="sr-only select-none">{children}</h3>;
1466}
1467
14581468function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
14591469 const ctx = use(TimelineRowCtx);
14601470 const { onImageExpand, onFileOpen } = ctx;
......@@ -1614,6 +1624,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
16141624 return (
16151625 <div className="group flex flex-col items-end gap-1">
16161626 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
1627 <MessageAuthorHeading>You</MessageAuthorHeading>
16171628 {(regularImages.length > 0 || userVideos.length > 0) && (
16181629 <div className="mb-2 grid max-w-[210px] grid-cols-2 gap-2">
16191630 {regularImages.map((image) => (
......@@ -1845,6 +1856,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
18451856 return (
18461857 <>
18471858 <div className="relative min-w-0 px-1 py-0.5">
1859 <MessageAuthorHeading>T3 Code</MessageAuthorHeading>
18481860 <AssistantCitationSource
18491861 messageId={row.message.id}
18501862 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
......@@ -1859,6 +1871,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
18591871 isStreaming={Boolean(row.message.streaming)}
18601872 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
18611873 skills={ctx.skills}
1874 headingLevelOffset={MESSAGE_HEADING_LEVEL}
18621875 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
18631876 onImageExpand={ctx.onImageExpand}
18641877 />
......@@ -3214,6 +3227,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
32143227 lineBreaks
32153228 parseRawHtml={false}
32163229 renderContextReference={props.renderContextReference}
3230 headingLevelOffset={MESSAGE_HEADING_LEVEL}
32173231 />
32183232 );
32193233});
apps/web/src/components/chat/ProposedPlanCard.tsx+5-1
......@@ -150,7 +150,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
150150 <div className="flex flex-wrap items-center justify-between gap-3">
151151 <div className="flex min-w-0 items-center gap-2">
152152 <Badge variant="secondary">Plan</Badge>
153 <p className="truncate text-sm font-medium text-foreground">{title}</p>
153 {/* Same heading level as the message author headings in the timeline,
154 so a plan's own headings nest beneath it in the outline. */}
155 <h3 className="truncate text-sm font-medium text-foreground">{title}</h3>
154156 </div>
155157 <Menu>
156158 <MenuTrigger
......@@ -177,6 +179,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
177179 cwd={cwd}
178180 threadRef={threadRef}
179181 isStreaming={false}
182 headingLevelOffset={3}
180183 />
181184 ) : (
182185 <ChatMarkdown
......@@ -184,6 +187,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
184187 cwd={cwd}
185188 threadRef={threadRef}
186189 isStreaming={false}
190 headingLevelOffset={3}
187191 />
188192 )}
189193 {canCollapse && !expanded ? (