diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index ce2b0f8efa53e8ce957aaafa501e23db8cd42bf1..4e22e68ca902db794625e9ba4bf7cf18d907b626 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -694,6 +694,28 @@ describe("ChatMarkdown artifact-template cards", () => {
});
});
+describe("ChatMarkdown heading levels", () => {
+ it("exposes headings below the host heading without changing their tags", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain('
Top
');
+ expect(html).toContain('Section
');
+ expect(html).toContain('Fine print
');
+ });
+
+ it("leaves heading levels alone when the markdown is not nested", () => {
+ const html = renderToStaticMarkup();
+
+ expect(html).toContain("Top
");
+ });
+});
+
describe("shouldUseMarkdownFileBrowserPrimaryAction", () => {
it("uses the browser when it is the only available primary action", () => {
expect(
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 4f113f6ed4be14008d271042abc8b13ed690f932..3aeaaa5b8443c3260c320ab8d9e5b8ff4fecb3ba 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -71,7 +71,11 @@ import React, {
useState,
type ReactNode,
} from "react";
-import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
+import type {
+ Components,
+ ExtraProps as ReactMarkdownExtraProps,
+ Options as ReactMarkdownOptions,
+} from "react-markdown";
import ReactMarkdown from "react-markdown";
import { toHtml } from "hast-util-to-html";
import { createIncrementalMarkdownPlugin } from "../markdown-incremental";
@@ -216,6 +220,10 @@ interface ChatMarkdownProps {
extraRemarkPlugins?: NonNullable;
/** Renders a `t3-context://` link as a chip; without it the link shows its label as text. */
renderContextReference?: ((reference: ChatMarkdownContextReference) => ReactNode) | undefined;
+ /** Levels added to each markdown heading in the accessibility tree so the
+ text nests under the heading that introduces it, such as a chat message's
+ author. Rendered tags and their styling are unchanged. */
+ headingLevelOffset?: number | undefined;
}
export interface ChatMarkdownContextReference {
@@ -2217,6 +2225,7 @@ function useChatMarkdownState({
imageBaseDir,
onImageExpand,
renderContextReference,
+ headingLevelOffset = 0,
}: ChatMarkdownProps) {
const { resolvedTheme } = useTheme();
const [localMediaPreview, setLocalMediaPreview] = useState(null);
@@ -2615,6 +2624,7 @@ function useChatMarkdownState({
expandMedia,
fileLinkChip,
renderContextReference,
+ headingLevelOffset,
imageBaseDir,
inlineCodeFileLinkMetaByText,
isStreaming,
@@ -2643,6 +2653,7 @@ function useChatMarkdownState({
expandMedia,
fileLinkChip,
renderContextReference,
+ headingLevelOffset,
imageBaseDir,
inlineCodeFileLinkMetaByText,
isStreaming,
@@ -2679,8 +2690,33 @@ const ChatMarkdownRendererContext = React.createContext<
ReturnType["componentState"]
>(null!);
+// Screen readers take a heading's level from its tag, which would let a `#` in a
+// message outrank the heading placed above it. Override only the exposed level:
+// the tag keeps driving the stylesheet and copy-as-markdown.
+function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) {
+ const Tag = `h${level}` as const;
+ return function MarkdownHeading({
+ node: _node,
+ ...props
+ }: ComponentProps & ReactMarkdownExtraProps) {
+ const { headingLevelOffset } = use(ChatMarkdownRendererContext);
+ return (
+ 0 ? Math.min(level + headingLevelOffset, 6) : undefined}
+ />
+ );
+ };
+}
+
// Keep component types stable when streaming changes the message state.
const CHAT_MARKDOWN_COMPONENTS = {
+ h1: markdownHeadingRenderer(1),
+ h2: markdownHeadingRenderer(2),
+ h3: markdownHeadingRenderer(3),
+ h4: markdownHeadingRenderer(4),
+ h5: markdownHeadingRenderer(5),
+ h6: markdownHeadingRenderer(6),
div: function MarkdownDiv({ node, children, ...props }) {
const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);
const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index d17c03ed32772392bcea25c26ec954d55e1d52f1..568a86721e4c754b1093417ccc15b9a56bb10282 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -1455,6 +1455,16 @@ function UserVideoAttachment({ file }: { readonly file: ChatFileAttachment }) {
);
}
+// Screen readers skim a transcript by heading, so every message announces its
+// author as one. The thread title in ChatHeader is an ; headings written
+// inside a message are exposed below this level. Visually hidden and excluded
+// from selection so sighted users and copied text are unaffected.
+const MESSAGE_HEADING_LEVEL = 3;
+
+function MessageAuthorHeading({ children }: { children: string }) {
+ return {children}
;
+}
+
function UserTimelineRow({ row }: { row: Extract }) {
const ctx = use(TimelineRowCtx);
const { onImageExpand, onFileOpen } = ctx;
@@ -1614,6 +1624,7 @@ function UserTimelineRow({ row }: { row: Extract
+
You
{(regularImages.length > 0 || userVideos.length > 0) && (
{regularImages.map((image) => (
@@ -1845,6 +1856,7 @@ function AssistantTimelineRow({ row }: { row: Extract
+
T3 Code
@@ -3214,6 +3227,7 @@ const UserMessageBody = memo(function UserMessageBody(props: {
lineBreaks
parseRawHtml={false}
renderContextReference={props.renderContextReference}
+ headingLevelOffset={MESSAGE_HEADING_LEVEL}
/>
);
});
diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx
index 9746857a8cacdccc02c0b9b37a33c7d7a936c447..9fcfaf4d17440188221cc7aa8d3e89175c6eddcb 100644
--- a/apps/web/src/components/chat/ProposedPlanCard.tsx
+++ b/apps/web/src/components/chat/ProposedPlanCard.tsx
@@ -150,7 +150,9 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
Plan
-
{title}
+ {/* Same heading level as the message author headings in the timeline,
+ so a plan's own headings nest beneath it in the outline. */}
+
{title}