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", () => {...@@ -694,6 +694,28 @@ describe("ChatMarkdown artifact-template cards", () => {
694 });694 });
695});695});
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
697describe("shouldUseMarkdownFileBrowserPrimaryAction", () => {719describe("shouldUseMarkdownFileBrowserPrimaryAction", () => {
698 it("uses the browser when it is the only available primary action", () => {720 it("uses the browser when it is the only available primary action", () => {
699 expect(721 expect(
apps/web/src/components/ChatMarkdown.tsx+37-1
...@@ -71,7 +71,11 @@ import React, {...@@ -71,7 +71,11 @@ import React, {
71 useState,71 useState,
72 type ReactNode,72 type ReactNode,
73} from "react";73} 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";
75import ReactMarkdown from "react-markdown";79import ReactMarkdown from "react-markdown";
76import { toHtml } from "hast-util-to-html";80import { toHtml } from "hast-util-to-html";
77import { createIncrementalMarkdownPlugin } from "../markdown-incremental";81import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
...@@ -216,6 +220,10 @@ interface ChatMarkdownProps {...@@ -216,6 +220,10 @@ interface ChatMarkdownProps {
216 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;220 extraRemarkPlugins?: NonNullable<ReactMarkdownOptions["remarkPlugins"]>;
217 /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */221 /** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */
218 renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;222 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;
219}227}
220228
221export interface ChatMarkdownContextReference {229export interface ChatMarkdownContextReference {
...@@ -2217,6 +2225,7 @@ function useChatMarkdownState({...@@ -2217,6 +2225,7 @@ function useChatMarkdownState({
2217 imageBaseDir,2225 imageBaseDir,
2218 onImageExpand,2226 onImageExpand,
2219 renderContextReference,2227 renderContextReference,
2228 headingLevelOffset = 0,
2220}: ChatMarkdownProps) {2229}: ChatMarkdownProps) {
2221 const { resolvedTheme } = useTheme();2230 const { resolvedTheme } = useTheme();
2222 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);2231 const [localMediaPreview, setLocalMediaPreview] = useState<ExpandedImagePreview | null>(null);
...@@ -2615,6 +2624,7 @@ function useChatMarkdownState({...@@ -2615,6 +2624,7 @@ function useChatMarkdownState({
2615 expandMedia,2624 expandMedia,
2616 fileLinkChip,2625 fileLinkChip,
2617 renderContextReference,2626 renderContextReference,
2627 headingLevelOffset,
2618 imageBaseDir,2628 imageBaseDir,
2619 inlineCodeFileLinkMetaByText,2629 inlineCodeFileLinkMetaByText,
2620 isStreaming,2630 isStreaming,
...@@ -2643,6 +2653,7 @@ function useChatMarkdownState({...@@ -2643,6 +2653,7 @@ function useChatMarkdownState({
2643 expandMedia,2653 expandMedia,
2644 fileLinkChip,2654 fileLinkChip,
2645 renderContextReference,2655 renderContextReference,
2656 headingLevelOffset,
2646 imageBaseDir,2657 imageBaseDir,
2647 inlineCodeFileLinkMetaByText,2658 inlineCodeFileLinkMetaByText,
2648 isStreaming,2659 isStreaming,
...@@ -2679,8 +2690,33 @@ const ChatMarkdownRendererContext = React.createContext<...@@ -2679,8 +2690,33 @@ const ChatMarkdownRendererContext = React.createContext<
2679 ReturnType<typeof useChatMarkdownState>["componentState"]2690 ReturnType<typeof useChatMarkdownState>["componentState"]
2680>(null!);2691>(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
2682// Keep component types stable when streaming changes the message state.2712// Keep component types stable when streaming changes the message state.
2683const CHAT_MARKDOWN_COMPONENTS = {2713const 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),
2684 div: function MarkdownDiv({ node, children, ...props }) {2720 div: function MarkdownDiv({ node, children, ...props }) {
2685 const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);2721 const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);
2686 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);2722 const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
apps/web/src/components/chat/MessagesTimeline.tsx+14
...@@ -1455,6 +1455,16 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {...@@ -1455,6 +1455,16 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
1455 );1455 );
1456}1456}
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
1458function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {1468function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
1459 const ctx = use(TimelineRowCtx);1469 const ctx = use(TimelineRowCtx);
1460 const { onImageExpand, onFileOpen } = ctx;1470 const { onImageExpand, onFileOpen } = ctx;
...@@ -1614,6 +1624,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"...@@ -1614,6 +1624,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message"
1614 return (1624 return (
1615 <div className="group flex flex-col items-end gap-1">1625 <div className="group flex flex-col items-end gap-1">
1616 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">1626 <div className="relative max-w-[80%] rounded-2xl bg-message p-3 text-message-foreground">
1627 <MessageAuthorHeading>You</MessageAuthorHeading>
1617 {(regularImages.length > 0 || userVideos.length > 0) && (1628 {(regularImages.length > 0 || userVideos.length > 0) && (
1618 <div className="mb-2 grid max-w-[210px] grid-cols-2 gap-2">1629 <div className="mb-2 grid max-w-[210px] grid-cols-2 gap-2">
1619 {regularImages.map((image) => (1630 {regularImages.map((image) => (
...@@ -1845,6 +1856,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -1845,6 +1856,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
1845 return (1856 return (
1846 <>1857 <>
1847 <div className="relative min-w-0 px-1 py-0.5">1858 <div className="relative min-w-0 px-1 py-0.5">
1859 <MessageAuthorHeading>T3 Code</MessageAuthorHeading>
1848 <AssistantCitationSource1860 <AssistantCitationSource
1849 messageId={row.message.id}1861 messageId={row.message.id}
1850 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}1862 {...(ctx.threadRef ? { threadRef: ctx.threadRef } : {})}
...@@ -1859,6 +1871,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess...@@ -1859,6 +1871,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
1859 isStreaming={Boolean(row.message.streaming)}1871 isStreaming={Boolean(row.message.streaming)}
1860 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}1872 lineBreaks={shouldPreserveAssistantLineBreaks(messageText)}
1861 skills={ctx.skills}1873 skills={ctx.skills}
1874 headingLevelOffset={MESSAGE_HEADING_LEVEL}
1862 onUseArtifactTemplate={ctx.onUseArtifactTemplate}1875 onUseArtifactTemplate={ctx.onUseArtifactTemplate}
1863 onImageExpand={ctx.onImageExpand}1876 onImageExpand={ctx.onImageExpand}
1864 />1877 />
...@@ -3214,6 +3227,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {...@@ -3214,6 +3227,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
3214 lineBreaks3227 lineBreaks
3215 parseRawHtml={false}3228 parseRawHtml={false}
3216 renderContextReference={props.renderContextReference}3229 renderContextReference={props.renderContextReference}
3230 headingLevelOffset={MESSAGE_HEADING_LEVEL}
3217 />3231 />
3218 );3232 );
3219});3233});
apps/web/src/components/chat/ProposedPlanCard.tsx+5-1
...@@ -150,7 +150,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -150,7 +150,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
150 <div className="flex flex-wrap items-center justify-between gap-3">150 <div className="flex flex-wrap items-center justify-between gap-3">
151 <div className="flex min-w-0 items-center gap-2">151 <div className="flex min-w-0 items-center gap-2">
152 <Badge variant="secondary">Plan</Badge>152 <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>
154 </div>156 </div>
155 <Menu>157 <Menu>
156 <MenuTrigger158 <MenuTrigger
...@@ -177,6 +179,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -177,6 +179,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
177 cwd={cwd}179 cwd={cwd}
178 threadRef={threadRef}180 threadRef={threadRef}
179 isStreaming={false}181 isStreaming={false}
182 headingLevelOffset={3}
180 />183 />
181 ) : (184 ) : (
182 <ChatMarkdown185 <ChatMarkdown
...@@ -184,6 +187,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({...@@ -184,6 +187,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
184 cwd={cwd}187 cwd={cwd}
185 threadRef={threadRef}188 threadRef={threadRef}
186 isStreaming={false}189 isStreaming={false}
190 headingLevelOffset={3}
187 />191 />
188 )}192 )}
189 {canCollapse && !expanded ? (193 {canCollapse && !expanded ? (