diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 1da3f35cb590..28df63f3f7d5 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -40,6 +40,7 @@ import { } from "react"; import { Markdown, + parseMarkdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, @@ -79,6 +80,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; +import { resolveMarkdownNodeTextDirection, type TextDirection } from "../../lib/textDirection"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -513,7 +515,7 @@ interface MarkdownStyleSets { interface MarkdownStyleSet { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; - readonly renderers: CustomRenderers; + readonly renderers: Readonly>; readonly nativeTextStyle: NativeMarkdownTextStyle; } @@ -637,6 +639,65 @@ function ArtifactTemplateCard(props: { ); } +const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: { + readonly text: string; + readonly markdownStyles: MarkdownStyleSet; +}) { + const sourceAst = useMemo(() => parseMarkdown(props.text, { gfm: true }), [props.text]); + const direction = resolveMarkdownNodeTextDirection(sourceAst); + const styles = useMemo( + () => ({ + ...props.markdownStyles.styles, + document: { + ...props.markdownStyles.styles.document, + direction, + }, + paragraph: { + ...props.markdownStyles.styles.paragraph, + direction, + }, + list: { + ...props.markdownStyles.styles.list, + direction, + }, + list_item: { + ...props.markdownStyles.styles.list_item, + direction, + }, + task_list_item: { + ...props.markdownStyles.styles.task_list_item, + direction, + }, + blockquote: { + ...props.markdownStyles.styles.blockquote, + direction, + ...(direction === "rtl" + ? { + borderLeftWidth: 0, + borderRightWidth: props.markdownStyles.styles.blockquote?.borderLeftWidth, + borderRightColor: props.markdownStyles.styles.blockquote?.borderLeftColor, + paddingLeft: 0, + paddingRight: props.markdownStyles.styles.blockquote?.paddingLeft, + } + : null), + }, + }), + [direction, props.markdownStyles.styles], + ); + + return ( + + {props.text} + + ); +}); + const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { readonly markdown: string; readonly markdownStyles: MarkdownStyleSet; @@ -673,15 +734,11 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { renderImage={props.renderImage} /> ) : ( - - {markdown} - + text={markdown} + markdownStyles={props.markdownStyles} + /> ); }); }); @@ -751,6 +808,8 @@ function MarkdownCodeBlock(props: { color: props.textColor, fontSize: props.fontSize, lineHeight: props.lineHeight, + textAlign: "left", + writingDirection: "ltr", ...(Platform.OS === "android" ? { includeFontPadding: false } : null), }} > @@ -949,6 +1008,7 @@ function useMarkdownStyles( copyTintColor: ColorValue, preserveSoftBreaks: boolean, highlightCode: boolean, + direction: TextDirection, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); @@ -996,7 +1056,7 @@ function useMarkdownStyles( ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( - + {node.children?.map((child, index) => { const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; if (child.type === "task_list_item") { @@ -1010,11 +1070,12 @@ function useMarkdownStyles( className="font-sans" style={{ width: ordered ? 22 : 12, - marginRight: 5, + marginLeft: direction === "rtl" ? 5 : 0, + marginRight: direction === "rtl" ? 0 : 5, color: inlineTextColor, fontSize: markdownFontSizes.m, lineHeight: markdownFontSizes.bodyLineHeight, - textAlign: ordered ? "right" : "center", + textAlign: ordered ? (direction === "rtl" ? "left" : "right") : "center", }} > {ordered ? `${start + index}.` : "•"} @@ -1044,6 +1105,8 @@ function useMarkdownStyles( color: inlineCodeTextColor, fontSize: markdownFontSizes.codeBlockFontSize, lineHeight: markdownFontSizes.bodyLineHeight, + textAlign: "left", + writingDirection: "ltr", }} > {value} @@ -1072,6 +1135,37 @@ function useMarkdownStyles( ), }); + const createDirectionalMarkdownRenderers = ( + inlineTextColor: string, + inlineCodeTextColor: string, + blockBackgroundColor: string, + blockTextColor: string, + copyTintColor: ColorValue, + preserveSoftBreaks: boolean, + highlightCode: boolean, + ): Readonly> => ({ + ltr: createMarkdownRenderers( + inlineTextColor, + inlineCodeTextColor, + blockBackgroundColor, + blockTextColor, + copyTintColor, + preserveSoftBreaks, + highlightCode, + "ltr", + ), + rtl: createMarkdownRenderers( + inlineTextColor, + inlineCodeTextColor, + blockBackgroundColor, + blockTextColor, + copyTintColor, + preserveSoftBreaks, + highlightCode, + "rtl", + ), + }); + const userTheme: PartialMarkdownTheme = { ...baseTheme, colors: { @@ -1121,7 +1215,7 @@ function useMarkdownStyles( user: { theme: userTheme, styles: userStyles, - renderers: createMarkdownRenderers( + renderers: createDirectionalMarkdownRenderers( markdownUserCodeText, markdownUserInlineCodeText, markdownUserFenceBg, @@ -1154,7 +1248,7 @@ function useMarkdownStyles( assistant: { theme: assistantTheme, styles: assistantStyles, - renderers: createMarkdownRenderers( + renderers: createDirectionalMarkdownRenderers( markdownCodeText, markdownInlineCodeText, markdownCodeBg, @@ -1462,16 +1556,7 @@ function UserMessageContent(props: { /> ); } - return ( - - {props.text} - - ); + return ; } return ( @@ -1503,15 +1588,11 @@ function UserMessageContent(props: { renderImage={props.renderImage} /> ) : ( - - {text} - + text={text} + markdownStyles={props.markdownStyles} + /> ); })} diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts new file mode 100644 index 000000000000..3dcd87fefeb8 --- /dev/null +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -0,0 +1,75 @@ +import type { MarkdownNode } from "react-native-nitro-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownNodeTextDirection, resolveTextDirection } from "./textDirection"; + +const documentWith = (...children: MarkdownNode[]): MarkdownNode => ({ + type: "document", + children, +}); + +describe("resolveTextDirection", () => { + it("resolves Hebrew and Arabic text as right-to-left", () => { + expect(resolveTextDirection("הודעה בעברית")).toBe("rtl"); + expect(resolveTextDirection("رسالة بالعربية")).toBe("rtl"); + }); + + it("resolves English and other left-to-right scripts as left-to-right", () => { + expect(resolveTextDirection("English message")).toBe("ltr"); + expect(resolveTextDirection("日本語のメッセージ")).toBe("ltr"); + }); + + it("ignores neutral prefixes before the first letter", () => { + expect(resolveTextDirection("👋 123... שלום")).toBe("rtl"); + expect(resolveTextDirection("(123) Hello")).toBe("ltr"); + }); + + it("defaults neutral-only content to left-to-right", () => { + expect(resolveTextDirection("👋 123...")).toBe("ltr"); + }); + + it("uses parsed prose instead of code content", () => { + expect( + resolveMarkdownNodeTextDirection( + documentWith( + { type: "code_block", content: "npm test" }, + { + type: "paragraph", + children: [ + { type: "code_inline", content: "English inline code" }, + { type: "text", content: " שלום" }, + ], + }, + ), + ), + ).toBe("rtl"); + + expect( + resolveMarkdownNodeTextDirection( + documentWith({ + type: "paragraph", + children: [ + { type: "code_inline", content: "שלום" }, + { type: "text", content: " English prose" }, + ], + }), + ), + ).toBe("ltr"); + }); + + it("ignores synthesized GitHub alert markers", () => { + expect( + resolveMarkdownNodeTextDirection( + documentWith({ + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "[!NOTE] הודעת התראה בעברית." }], + }, + ], + }), + ), + ).toBe("rtl"); + }); +}); diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts new file mode 100644 index 000000000000..dc22dfa012e0 --- /dev/null +++ b/apps/mobile/src/lib/textDirection.ts @@ -0,0 +1,32 @@ +import type { MarkdownNode } from "react-native-nitro-markdown"; + +export type TextDirection = "ltr" | "rtl"; + +const LETTER_CHARACTER = /^\p{Letter}$/u; +const RTL_SCRIPT_CHARACTER = + /^[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufeff\u{10800}-\u{10fff}\u{1e800}-\u{1eeff}]$/u; +const GITHUB_ALERT_MARKER = /\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/giu; + +function markdownProse(node: MarkdownNode): string { + if (node.type === "code_inline" || node.type === "code_block" || node.type === "image") { + return ""; + } + + if (node.type === "text") { + return (node.content ?? "").replace(GITHUB_ALERT_MARKER, ""); + } + + return node.children?.map(markdownProse).join("") ?? ""; +} + +export function resolveTextDirection(text: string): TextDirection { + for (const character of text) { + if (!LETTER_CHARACTER.test(character)) continue; + return RTL_SCRIPT_CHARACTER.test(character) ? "rtl" : "ltr"; + } + return "ltr"; +} + +export function resolveMarkdownNodeTextDirection(node: MarkdownNode): TextDirection { + return resolveTextDirection(markdownProse(node)); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a055759e638a..72ffc88ba4c0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -240,6 +240,7 @@ function CodexArtifactTemplateCard(props: { data-artifact-kind={props.template.artifactKind} data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`} data-skill-name={props.template.skillName} + dir="auto" >
@@ -656,6 +657,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { ref={containerRef} className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} + dir="ltr" >
@@ -1715,6 +1718,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ render={ hasPrimaryAction ? ( : the stylesheet mutes those, and an alert's body is ordinary // text under a colored title — which is how the host renders it. return ( -
+

- {alert.label} + {alert.label}

{children}
@@ -2328,7 +2333,7 @@ function ChatMarkdown({ } } return ( - + {children} ); @@ -2382,13 +2387,23 @@ function ChatMarkdown({ table({ node: _node, ...props }) { return ; }, + th({ node: _node, ...props }) { + return ; + }, + td({ node: _node, ...props }) { + return ; + }, details({ node: _node, children, open: detailsOpen }) { return {children}; }, pre({ node, children, ...props }) { const codeBlock = extractCodeBlock(children); if (!codeBlock) { - return
{children}
; + return ( +
+              {children}
+            
+ ); } const language = extractFenceLanguage(codeBlock.className); @@ -2400,8 +2415,20 @@ function ChatMarkdown({ fenceTitle={fenceTitle} theme={resolvedTheme} > - {children}}> - {children}}> + + {children} + + } + > + + {children} + + } + > { expect(markup).toContain("rounded-2xl bg-message p-3"); }); + it("resolves user and assistant message direction from each message", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup.match(/class="chat-markdown[^"]*" dir="auto"/g)).toHaveLength(4); + }); + + it("keeps code left-to-right inside auto-directed messages", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toMatch(/]*dir="ltr"[^>]*class="[^"]*chat-markdown-file-link/); + expect(markup).toMatch(/]*dir="ltr"[^>]*>vp test<\/code>/); + expect(markup).toMatch(/class="chat-markdown-codeblock[^"]*"[^>]*dir="ltr"/); + }); + + it("keeps table structure left-to-right while resolving each cell direction", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toMatch(/class="chat-markdown-table-container"[^>]*dir="ltr"/); + expect(markup.match(/<(?:th|td)[^>]*dir="auto"/g)).toHaveLength(4); + }); + + it("uses logical spacing for GitHub alerts inside auto-directed messages", () => { + const markup = renderToStaticMarkup( + [!NOTE]\n> הודעת התראה בעברית.")]} + />, + ); + + expect(markup).toMatch(/
Note"); + expect(markup).not.toContain("border-l-2"); + }); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -876,7 +941,9 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain( + '<tag attr="x">', + ); expect(markup).toContain("<root><child enabled="true" /></root>"); }); diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 6d026ea58cce..a5c68e79c2ba 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -86,9 +86,9 @@ function SkillChip(props: { skill: InlineSkill; rawText: string }) { className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} /> - + {formatProviderSkillDisplayName(props.skill)} - + ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index b58584291b4c..18950f37255b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1632,7 +1632,7 @@ code { custom property, so without this a task-list under a 3+ digit ordered list would inherit the outer gutter instead of its own default. */ --list-gutter: 1.25rem; - padding-left: 1.25rem; + padding-inline-start: 1.25rem; list-style-type: disc; } @@ -1643,7 +1643,7 @@ code { nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { --list-gutter: 1.25rem; - padding-left: var(--list-gutter, 1.25rem); + padding-inline-start: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1677,7 +1677,8 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); + margin: 0 0 0.15em; + margin-inline: calc(-1 * var(--list-gutter, 1.25rem)) 0.35em; vertical-align: middle; } @@ -1701,8 +1702,8 @@ code { } .chat-markdown blockquote { - border-left: 2px solid var(--contrast-border); - padding-left: 0.8rem; + border-inline-start: 2px solid var(--contrast-border); + padding-inline-start: 0.8rem; color: var(--contrast-muted-foreground); } @@ -1827,7 +1828,7 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + text-align: start; } .chat-markdown thead th {