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