From 712f71518834156bf3631acdbc1920f3f39b1470 Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:54:07 +0300 Subject: [PATCH 01/13] fix(chat): align messages with their text direction --- .../src/features/threads/ThreadFeed.tsx | 64 +++++++++++-------- apps/mobile/src/lib/textDirection.test.ts | 24 +++++++ apps/mobile/src/lib/textDirection.ts | 13 ++++ apps/web/src/components/ChatMarkdown.tsx | 26 ++++++-- .../components/chat/MessagesTimeline.test.tsx | 36 ++++++++++- 5 files changed, 132 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/src/lib/textDirection.test.ts create mode 100644 apps/mobile/src/lib/textDirection.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f00736772766..62c90a1036cb 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -51,6 +51,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; +import { resolveTextDirection } from "../../lib/textDirection"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -336,6 +337,8 @@ function MarkdownCodeBlock(props: { color: props.textColor, fontSize: props.fontSize, lineHeight: props.lineHeight, + textAlign: "left", + writingDirection: "ltr", ...(Platform.OS === "android" ? { includeFontPadding: false } : null), }} > @@ -622,6 +625,8 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe color: inlineCodeTextColor, fontSize: markdownFontSizes.codeBlockFontSize, lineHeight: markdownFontSizes.bodyLineHeight, + textAlign: "left", + writingDirection: "ltr", }} > {value} @@ -792,6 +797,33 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe ]); } +const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: { + readonly text: string; + readonly markdownStyles: MarkdownStyleSet; +}) { + const styles = useMemo( + () => ({ + ...props.markdownStyles.styles, + paragraph: { + ...props.markdownStyles.styles.paragraph, + direction: resolveTextDirection(props.text), + }, + }), + [props.markdownStyles.styles, props.text], + ); + + return ( + + {props.text} + + ); +}); + function renderFeedEntry( info: { item: ThreadFeedEntry; index: number }, props: Pick & { @@ -956,14 +988,7 @@ function renderFeedEntry( onLinkPress={props.onMarkdownLinkPress} /> ) : ( - - {message.text} - + ) ) : null} {attachments.map((attachment) => { @@ -1054,16 +1079,7 @@ function UserMessageContent(props: { /> ); } - return ( - - {props.text} - - ); + return ; } return ( @@ -1094,15 +1110,11 @@ function UserMessageContent(props: { onLinkPress={props.onLinkPress} /> ) : ( - - {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..b90276a24428 --- /dev/null +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveTextDirection } from "./textDirection"; + +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"); + }); +}); diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts new file mode 100644 index 000000000000..b17b0989a43d --- /dev/null +++ b/apps/mobile/src/lib/textDirection.ts @@ -0,0 +1,13 @@ +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; + +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"; +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c4548540e2ce..946ad360490b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -674,6 +674,7 @@ function MarkdownCodeBlock({ className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-[var(--radius)] border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32" data-language={language} data-wrap={wrapped ? "true" : "false"} + dir="ltr" >
@@ -1724,7 +1725,7 @@ function ChatMarkdown({ } } return ( - + {children} ); @@ -1738,7 +1739,11 @@ function ChatMarkdown({ pre({ node, children, ...props }) { const codeBlock = extractCodeBlock(children); if (!codeBlock) { - return
{children}
; + return ( +
+              {children}
+            
+ ); } const language = extractFenceLanguage(codeBlock.className); @@ -1750,8 +1755,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"[^>]*>vp test<\/code>/); + expect(markup).toMatch(/class="chat-markdown-codeblock[^"]*"[^>]*dir="ltr"/); + }); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -520,7 +552,9 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain( + '<tag attr="x">', + ); expect(markup).toContain("<root><child enabled="true" /></root>"); }); From 4f0ab2ab525655e3354c49b5b0be0a6e1c0523c4 Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:09:12 +0300 Subject: [PATCH 02/13] fix(chat): preserve RTL markdown layout --- .../src/features/threads/ThreadFeed.tsx | 4 ++-- apps/mobile/src/lib/textDirection.test.ts | 8 ++++++- apps/mobile/src/lib/textDirection.ts | 5 +++++ apps/web/src/components/ChatMarkdown.tsx | 8 +++++++ .../components/chat/MessagesTimeline.test.tsx | 22 ++++++++++++++++++- apps/web/src/index.css | 15 +++++++------ 6 files changed, 51 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 62c90a1036cb..608f898e1216 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -51,7 +51,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; -import { resolveTextDirection } from "../../lib/textDirection"; +import { resolveMarkdownTextDirection } from "../../lib/textDirection"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -806,7 +806,7 @@ const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: { ...props.markdownStyles.styles, paragraph: { ...props.markdownStyles.styles.paragraph, - direction: resolveTextDirection(props.text), + direction: resolveMarkdownTextDirection(props.text), }, }), [props.markdownStyles.styles, props.text], diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts index b90276a24428..efec060e88ff 100644 --- a/apps/mobile/src/lib/textDirection.test.ts +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveTextDirection } from "./textDirection"; +import { resolveMarkdownTextDirection, resolveTextDirection } from "./textDirection"; describe("resolveTextDirection", () => { it("resolves Hebrew and Arabic text as right-to-left", () => { @@ -21,4 +21,10 @@ describe("resolveTextDirection", () => { it("defaults neutral-only content to left-to-right", () => { expect(resolveTextDirection("👋 123...")).toBe("ltr"); }); + + it("ignores inline and fenced code when resolving markdown prose", () => { + expect(resolveMarkdownTextDirection("`npm` שלום")).toBe("rtl"); + expect(resolveMarkdownTextDirection("```sh\nnpm test\n```\n\nשלום")).toBe("rtl"); + expect(resolveMarkdownTextDirection("`שלום` English prose")).toBe("ltr"); + }); }); diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts index b17b0989a43d..dd37de9aa670 100644 --- a/apps/mobile/src/lib/textDirection.ts +++ b/apps/mobile/src/lib/textDirection.ts @@ -3,6 +3,7 @@ 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 MARKDOWN_CODE = /```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`/g; export function resolveTextDirection(text: string): TextDirection { for (const character of text) { @@ -11,3 +12,7 @@ export function resolveTextDirection(text: string): TextDirection { } return "ltr"; } + +export function resolveMarkdownTextDirection(markdown: string): TextDirection { + return resolveTextDirection(markdown.replace(MARKDOWN_CODE, "")); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 946ad360490b..4cb652a5e736 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -467,6 +467,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { ref={containerRef} className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} + dir="ltr" > ; }, + th({ node: _node, ...props }) { + return ; + }, + td({ node: _node, ...props }) { + return ; + }, details({ node: _node, children, open: detailsOpen }) { return {children}; }, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e54f2cb69f0e..79f7abb85fae 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -495,18 +495,38 @@ describe("MessagesTimeline", () => { 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("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fea03489b7fa..3388caa70c2c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1640,7 +1640,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; } @@ -1651,7 +1651,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; } @@ -1681,7 +1681,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; } @@ -1705,8 +1706,8 @@ code { } .chat-markdown blockquote { - border-left: 2px solid var(--border); - padding-left: 0.8rem; + border-inline-start: 2px solid var(--border); + padding-inline-start: 0.8rem; color: var(--muted-foreground); } @@ -1720,7 +1721,7 @@ code { .chat-markdown section[data-footnotes] ol { margin: 0; - padding-left: 1.25rem; + padding-inline-start: 1.25rem; } .chat-markdown section[data-footnotes] li + li { @@ -1832,7 +1833,7 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + text-align: start; } .chat-markdown thead th { From 364f2cb76541b1c2ea31867c00a779aaab2faf0e Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:15:06 +0300 Subject: [PATCH 03/13] fix(web): mirror alert chrome in RTL --- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../src/components/chat/MessagesTimeline.test.tsx | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4cb652a5e736..5baad4142302 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1571,7 +1571,7 @@ function ChatMarkdown({ // Not a
: 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} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 79f7abb85fae..5b9935fb4b48 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -527,6 +527,18 @@ describe("MessagesTimeline", () => { 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(/

{ const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( From 8f8a06919212146928db81e4b073b69be40b7650 Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:05:44 +0300 Subject: [PATCH 04/13] fix(mobile): parse markdown fences before RTL detection --- apps/mobile/src/lib/textDirection.test.ts | 8 +++++ apps/mobile/src/lib/textDirection.ts | 37 +++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts index efec060e88ff..34d2f0c9d66d 100644 --- a/apps/mobile/src/lib/textDirection.test.ts +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -27,4 +27,12 @@ describe("resolveTextDirection", () => { expect(resolveMarkdownTextDirection("```sh\nnpm test\n```\n\nשלום")).toBe("rtl"); expect(resolveMarkdownTextDirection("`שלום` English prose")).toBe("ltr"); }); + + it("does not close a long fence at a shorter nested marker", () => { + expect( + resolveMarkdownTextDirection( + "````text\n``` nested marker\nEnglish inside the fence\n````\n\nשלום", + ), + ).toBe("rtl"); + }); }); diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts index dd37de9aa670..4e61dfa1061d 100644 --- a/apps/mobile/src/lib/textDirection.ts +++ b/apps/mobile/src/lib/textDirection.ts @@ -3,7 +3,40 @@ 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 MARKDOWN_CODE = /```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`/g; +const INLINE_MARKDOWN_CODE = /`[^`\n]*`/g; + +function stripFencedMarkdownCode(markdown: string): string { + let fence: { character: "`" | "~"; length: number } | undefined; + const proseLines: string[] = []; + + for (const line of markdown.split(/\r?\n/)) { + const fenceLine = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + + if (!fence) { + const marker = fenceLine?.[1]; + const suffix = fenceLine?.[2] ?? ""; + if (marker && (marker[0] === "~" || !suffix.includes("`"))) { + fence = { character: marker[0] as "`" | "~", length: marker.length }; + continue; + } + + proseLines.push(line); + continue; + } + + const marker = fenceLine?.[1]; + const suffix = fenceLine?.[2] ?? ""; + if ( + marker?.[0] === fence.character && + marker.length >= fence.length && + /^[\t ]*$/.test(suffix) + ) { + fence = undefined; + } + } + + return proseLines.join("\n"); +} export function resolveTextDirection(text: string): TextDirection { for (const character of text) { @@ -14,5 +47,5 @@ export function resolveTextDirection(text: string): TextDirection { } export function resolveMarkdownTextDirection(markdown: string): TextDirection { - return resolveTextDirection(markdown.replace(MARKDOWN_CODE, "")); + return resolveTextDirection(stripFencedMarkdownCode(markdown).replace(INLINE_MARKDOWN_CODE, "")); } From ea22c7828d5b3884d256c1efc49cbd419fc0906c Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:13:56 +0300 Subject: [PATCH 05/13] fix(mobile): align RTL markdown containers --- .../src/features/threads/ThreadFeed.tsx | 89 ++++++++++++++++--- 1 file changed, 78 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b176a0bd0cb1..c6a032cd75e3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -79,7 +79,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; -import { resolveMarkdownTextDirection } from "../../lib/textDirection"; +import { resolveMarkdownTextDirection, type TextDirection } from "../../lib/textDirection"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -514,7 +514,7 @@ interface MarkdownStyleSets { interface MarkdownStyleSet { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; - readonly renderers: CustomRenderers; + readonly renderers: Readonly>; readonly nativeTextStyle: NativeMarkdownTextStyle; } @@ -642,21 +642,51 @@ const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; }) { + const direction = resolveMarkdownTextDirection(props.text); const styles = useMemo( () => ({ ...props.markdownStyles.styles, + document: { + ...props.markdownStyles.styles.document, + direction, + }, paragraph: { ...props.markdownStyles.styles.paragraph, - direction: resolveMarkdownTextDirection(props.text), + 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), }, }), - [props.markdownStyles.styles, props.text], + [direction, props.markdownStyles.styles], ); return ( @@ -975,6 +1005,7 @@ function useMarkdownStyles( copyTintColor: ColorValue, preserveSoftBreaks: boolean, highlightCode: boolean, + direction: TextDirection, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); @@ -1022,7 +1053,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") { @@ -1031,16 +1062,21 @@ function useMarkdownStyles( ); } return ( - + {ordered ? `${start + index}.` : "•"} @@ -1100,6 +1136,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: { @@ -1149,7 +1216,7 @@ function useMarkdownStyles( user: { theme: userTheme, styles: userStyles, - renderers: createMarkdownRenderers( + renderers: createDirectionalMarkdownRenderers( markdownUserCodeText, markdownUserInlineCodeText, markdownUserFenceBg, @@ -1182,7 +1249,7 @@ function useMarkdownStyles( assistant: { theme: assistantTheme, styles: assistantStyles, - renderers: createMarkdownRenderers( + renderers: createDirectionalMarkdownRenderers( markdownCodeText, markdownInlineCodeText, markdownCodeBg, From 39da1eefe7dd16f5afc9e37b633431de26b46df2 Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:18:42 +0300 Subject: [PATCH 06/13] fix(chat): preserve RTL direction in markdown edge cases --- apps/mobile/src/lib/textDirection.test.ts | 10 ++++++++++ apps/mobile/src/lib/textDirection.ts | 12 +++++++++--- apps/web/src/components/ChatMarkdown.tsx | 5 ++++- .../src/components/chat/MessagesTimeline.test.tsx | 1 + apps/web/src/index.css | 1 - 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts index 34d2f0c9d66d..53cdba54f999 100644 --- a/apps/mobile/src/lib/textDirection.test.ts +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -35,4 +35,14 @@ describe("resolveTextDirection", () => { ), ).toBe("rtl"); }); + + it("handles fenced code with legacy line endings and blockquote prefixes", () => { + expect(resolveMarkdownTextDirection("```sh\rnpm test\r```\r\rשלום")).toBe("rtl"); + expect(resolveMarkdownTextDirection("> ```\n> שלום\n> ```\n\nEnglish prose")).toBe("ltr"); + }); + + it("ignores GitHub alert markers when resolving the prose direction", () => { + expect(resolveMarkdownTextDirection("> [!NOTE]\n> הודעת התראה בעברית.")).toBe("rtl"); + expect(resolveMarkdownTextDirection("> [!WARNING]\n> English warning.")).toBe("ltr"); + }); }); diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts index 4e61dfa1061d..e163b78a32aa 100644 --- a/apps/mobile/src/lib/textDirection.ts +++ b/apps/mobile/src/lib/textDirection.ts @@ -4,13 +4,15 @@ 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 INLINE_MARKDOWN_CODE = /`[^`\n]*`/g; +const GITHUB_ALERT_MARKER = + /^ {0,3}(?:> {0,3})+\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][\t ]*$/gimu; function stripFencedMarkdownCode(markdown: string): string { let fence: { character: "`" | "~"; length: number } | undefined; const proseLines: string[] = []; - for (const line of markdown.split(/\r?\n/)) { - const fenceLine = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + for (const line of markdown.split(/\r\n|\r|\n/)) { + const fenceLine = line.match(/^ {0,3}(?:> {0,3})*(`{3,}|~{3,})(.*)$/); if (!fence) { const marker = fenceLine?.[1]; @@ -47,5 +49,9 @@ export function resolveTextDirection(text: string): TextDirection { } export function resolveMarkdownTextDirection(markdown: string): TextDirection { - return resolveTextDirection(stripFencedMarkdownCode(markdown).replace(INLINE_MARKDOWN_CODE, "")); + return resolveTextDirection( + stripFencedMarkdownCode(markdown) + .replace(GITHUB_ALERT_MARKER, "") + .replace(INLINE_MARKDOWN_CODE, ""), + ); } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e446ddc4e3f8..70aae45908f8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2148,7 +2148,10 @@ function ChatMarkdown({ // text under a colored title — which is how the host renders it. return (
-

+

{alert.label}

diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 5997e4e2ee99..c83284f019e0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -892,6 +892,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toMatch(/
Date: Mon, 31 Aug 2026 13:26:51 +0300 Subject: [PATCH 07/13] fix(chat): ignore markdown code during direction detection --- apps/mobile/src/lib/textDirection.test.ts | 6 +++ apps/mobile/src/lib/textDirection.ts | 49 +++++++++++++++++-- apps/web/src/components/ChatMarkdown.tsx | 7 +-- .../components/chat/MessagesTimeline.test.tsx | 2 +- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts index 53cdba54f999..b3208920b070 100644 --- a/apps/mobile/src/lib/textDirection.test.ts +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -24,10 +24,16 @@ describe("resolveTextDirection", () => { it("ignores inline and fenced code when resolving markdown prose", () => { expect(resolveMarkdownTextDirection("`npm` שלום")).toBe("rtl"); + expect(resolveMarkdownTextDirection("``English `code` inside`` שלום")).toBe("rtl"); expect(resolveMarkdownTextDirection("```sh\nnpm test\n```\n\nשלום")).toBe("rtl"); expect(resolveMarkdownTextDirection("`שלום` English prose")).toBe("ltr"); }); + it("ignores indented code blocks when resolving markdown prose", () => { + expect(resolveMarkdownTextDirection(" English command\n\nשלום")).toBe("rtl"); + expect(resolveMarkdownTextDirection("> English command\n\nשלום")).toBe("rtl"); + }); + it("does not close a long fence at a shorter nested marker", () => { expect( resolveMarkdownTextDirection( diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts index e163b78a32aa..a59476e4d377 100644 --- a/apps/mobile/src/lib/textDirection.ts +++ b/apps/mobile/src/lib/textDirection.ts @@ -3,9 +3,9 @@ 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 INLINE_MARKDOWN_CODE = /`[^`\n]*`/g; const GITHUB_ALERT_MARKER = /^ {0,3}(?:> {0,3})+\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][\t ]*$/gimu; +const INDENTED_CODE_LINE = /^(?: {4}|\t| {0,3}(?:> ?)+(?: {4}|\t))/; function stripFencedMarkdownCode(markdown: string): string { let fence: { character: "`" | "~"; length: number } | undefined; @@ -22,7 +22,7 @@ function stripFencedMarkdownCode(markdown: string): string { continue; } - proseLines.push(line); + if (!INDENTED_CODE_LINE.test(line)) proseLines.push(line); continue; } @@ -40,6 +40,47 @@ function stripFencedMarkdownCode(markdown: string): string { return proseLines.join("\n"); } +function stripInlineMarkdownCode(markdown: string): string { + let prose = ""; + let index = 0; + + while (index < markdown.length) { + if (markdown[index] !== "`") { + prose += markdown[index]; + index += 1; + continue; + } + + const openingStart = index; + while (markdown[index] === "`") index += 1; + const delimiterLength = index - openingStart; + let searchIndex = index; + let closingEnd: number | undefined; + + while (searchIndex < markdown.length) { + const closingStart = markdown.indexOf("`", searchIndex); + if (closingStart === -1) break; + + let runEnd = closingStart; + while (markdown[runEnd] === "`") runEnd += 1; + if (runEnd - closingStart === delimiterLength) { + closingEnd = runEnd; + break; + } + searchIndex = runEnd; + } + + if (closingEnd !== undefined) { + index = closingEnd; + continue; + } + + prose += markdown.slice(openingStart, index); + } + + return prose; +} + export function resolveTextDirection(text: string): TextDirection { for (const character of text) { if (!LETTER_CHARACTER.test(character)) continue; @@ -50,8 +91,6 @@ export function resolveTextDirection(text: string): TextDirection { export function resolveMarkdownTextDirection(markdown: string): TextDirection { return resolveTextDirection( - stripFencedMarkdownCode(markdown) - .replace(GITHUB_ALERT_MARKER, "") - .replace(INLINE_MARKDOWN_CODE, ""), + stripInlineMarkdownCode(stripFencedMarkdownCode(markdown).replace(GITHUB_ALERT_MARKER, "")), ); } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 70aae45908f8..5670096b9f3a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -2148,12 +2148,9 @@ function ChatMarkdown({ // text under a colored title — which is how the host renders it. return (
-

+

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

{children}
diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c83284f019e0..d00317c3b620 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -892,7 +892,7 @@ describe("MessagesTimeline", () => { ); expect(markup).toMatch(/
Note"); expect(markup).not.toContain("border-l-2"); }); From c094fe04d37c12a768315222073d98c300cba1cb Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:38:27 +0300 Subject: [PATCH 08/13] refactor(mobile): derive text direction from markdown AST --- .../src/features/threads/ThreadFeed.tsx | 7 +- apps/mobile/src/lib/textDirection.test.ts | 67 ++++++++++----- apps/mobile/src/lib/textDirection.ts | 86 +++---------------- 3 files changed, 60 insertions(+), 100 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c6a032cd75e3..aa1f6990e1f9 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,7 +80,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; -import { resolveMarkdownTextDirection, type TextDirection } from "../../lib/textDirection"; +import { resolveMarkdownNodeTextDirection, type TextDirection } from "../../lib/textDirection"; import { hasNativeSelectableMarkdownText, SelectableMarkdownText, @@ -642,7 +643,8 @@ const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: { readonly text: string; readonly markdownStyles: MarkdownStyleSet; }) { - const direction = resolveMarkdownTextDirection(props.text); + const sourceAst = useMemo(() => parseMarkdown(props.text, { gfm: true }), [props.text]); + const direction = resolveMarkdownNodeTextDirection(sourceAst); const styles = useMemo( () => ({ ...props.markdownStyles.styles, @@ -687,6 +689,7 @@ const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: { diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts index b3208920b070..3dcd87fefeb8 100644 --- a/apps/mobile/src/lib/textDirection.test.ts +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -1,6 +1,12 @@ +import type { MarkdownNode } from "react-native-nitro-markdown"; import { describe, expect, it } from "vite-plus/test"; -import { resolveMarkdownTextDirection, resolveTextDirection } from "./textDirection"; +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", () => { @@ -22,33 +28,48 @@ describe("resolveTextDirection", () => { expect(resolveTextDirection("👋 123...")).toBe("ltr"); }); - it("ignores inline and fenced code when resolving markdown prose", () => { - expect(resolveMarkdownTextDirection("`npm` שלום")).toBe("rtl"); - expect(resolveMarkdownTextDirection("``English `code` inside`` שלום")).toBe("rtl"); - expect(resolveMarkdownTextDirection("```sh\nnpm test\n```\n\nשלום")).toBe("rtl"); - expect(resolveMarkdownTextDirection("`שלום` English prose")).toBe("ltr"); - }); - - it("ignores indented code blocks when resolving markdown prose", () => { - expect(resolveMarkdownTextDirection(" English command\n\nשלום")).toBe("rtl"); - expect(resolveMarkdownTextDirection("> English command\n\nשלום")).toBe("rtl"); - }); - - it("does not close a long fence at a shorter nested marker", () => { + it("uses parsed prose instead of code content", () => { expect( - resolveMarkdownTextDirection( - "````text\n``` nested marker\nEnglish inside the fence\n````\n\nשלום", + resolveMarkdownNodeTextDirection( + documentWith( + { type: "code_block", content: "npm test" }, + { + type: "paragraph", + children: [ + { type: "code_inline", content: "English inline code" }, + { type: "text", content: " שלום" }, + ], + }, + ), ), ).toBe("rtl"); - }); - it("handles fenced code with legacy line endings and blockquote prefixes", () => { - expect(resolveMarkdownTextDirection("```sh\rnpm test\r```\r\rשלום")).toBe("rtl"); - expect(resolveMarkdownTextDirection("> ```\n> שלום\n> ```\n\nEnglish prose")).toBe("ltr"); + expect( + resolveMarkdownNodeTextDirection( + documentWith({ + type: "paragraph", + children: [ + { type: "code_inline", content: "שלום" }, + { type: "text", content: " English prose" }, + ], + }), + ), + ).toBe("ltr"); }); - it("ignores GitHub alert markers when resolving the prose direction", () => { - expect(resolveMarkdownTextDirection("> [!NOTE]\n> הודעת התראה בעברית.")).toBe("rtl"); - expect(resolveMarkdownTextDirection("> [!WARNING]\n> English warning.")).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 index a59476e4d377..dc22dfa012e0 100644 --- a/apps/mobile/src/lib/textDirection.ts +++ b/apps/mobile/src/lib/textDirection.ts @@ -1,84 +1,22 @@ +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 = - /^ {0,3}(?:> {0,3})+\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][\t ]*$/gimu; -const INDENTED_CODE_LINE = /^(?: {4}|\t| {0,3}(?:> ?)+(?: {4}|\t))/; - -function stripFencedMarkdownCode(markdown: string): string { - let fence: { character: "`" | "~"; length: number } | undefined; - const proseLines: string[] = []; - - for (const line of markdown.split(/\r\n|\r|\n/)) { - const fenceLine = line.match(/^ {0,3}(?:> {0,3})*(`{3,}|~{3,})(.*)$/); - - if (!fence) { - const marker = fenceLine?.[1]; - const suffix = fenceLine?.[2] ?? ""; - if (marker && (marker[0] === "~" || !suffix.includes("`"))) { - fence = { character: marker[0] as "`" | "~", length: marker.length }; - continue; - } +const GITHUB_ALERT_MARKER = /\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/giu; - if (!INDENTED_CODE_LINE.test(line)) proseLines.push(line); - continue; - } - - const marker = fenceLine?.[1]; - const suffix = fenceLine?.[2] ?? ""; - if ( - marker?.[0] === fence.character && - marker.length >= fence.length && - /^[\t ]*$/.test(suffix) - ) { - fence = undefined; - } +function markdownProse(node: MarkdownNode): string { + if (node.type === "code_inline" || node.type === "code_block" || node.type === "image") { + return ""; } - return proseLines.join("\n"); -} - -function stripInlineMarkdownCode(markdown: string): string { - let prose = ""; - let index = 0; - - while (index < markdown.length) { - if (markdown[index] !== "`") { - prose += markdown[index]; - index += 1; - continue; - } - - const openingStart = index; - while (markdown[index] === "`") index += 1; - const delimiterLength = index - openingStart; - let searchIndex = index; - let closingEnd: number | undefined; - - while (searchIndex < markdown.length) { - const closingStart = markdown.indexOf("`", searchIndex); - if (closingStart === -1) break; - - let runEnd = closingStart; - while (markdown[runEnd] === "`") runEnd += 1; - if (runEnd - closingStart === delimiterLength) { - closingEnd = runEnd; - break; - } - searchIndex = runEnd; - } - - if (closingEnd !== undefined) { - index = closingEnd; - continue; - } - - prose += markdown.slice(openingStart, index); + if (node.type === "text") { + return (node.content ?? "").replace(GITHUB_ALERT_MARKER, ""); } - return prose; + return node.children?.map(markdownProse).join("") ?? ""; } export function resolveTextDirection(text: string): TextDirection { @@ -89,8 +27,6 @@ export function resolveTextDirection(text: string): TextDirection { return "ltr"; } -export function resolveMarkdownTextDirection(markdown: string): TextDirection { - return resolveTextDirection( - stripInlineMarkdownCode(stripFencedMarkdownCode(markdown).replace(GITHUB_ALERT_MARKER, "")), - ); +export function resolveMarkdownNodeTextDirection(node: MarkdownNode): TextDirection { + return resolveTextDirection(markdownProse(node)); } From 028c681f9814466fc901a05f210935b85a7d1b5d Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:50:39 +0300 Subject: [PATCH 09/13] fix(mobile): keep RTL list markers at inline start --- apps/mobile/src/features/threads/ThreadFeed.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index aa1f6990e1f9..28df63f3f7d5 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1065,11 +1065,7 @@ function useMarkdownStyles( ); } return ( - + Date: Mon, 31 Aug 2026 13:59:53 +0300 Subject: [PATCH 10/13] fix(web): align markdown details summaries logically --- apps/web/src/components/ChatMarkdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5670096b9f3a..08c89cd866f3 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -743,7 +743,7 @@ function MarkdownDetails({ data-markdown-details-open={isOpen ? "true" : "false"} > Date: Mon, 31 Aug 2026 14:09:03 +0300 Subject: [PATCH 11/13] fix(web): isolate skill labels from message direction --- apps/web/src/components/chat/SkillInlineText.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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)} - + ); From f124b9fee00720288855b0535eb8199a46e91bc4 Mon Sep 17 00:00:00 2001 From: ShlomiPorush <5992161+ShlomiPorush@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:11:24 +0300 Subject: [PATCH 12/13] fix(web): isolate artifact cards from message direction --- apps/web/src/components/ChatMarkdown.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 08c89cd866f3..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" >
From 50a19930bec1517e23830a7c7318438dea1cc2f6 Mon Sep 17 00:00:00 2001 From: Haitham Assoli Date: Sun, 13 Sep 2026 19:30:04 +0300 Subject: [PATCH 13/13] fix(chat): detect RTL-dominant mixed text --- apps/mobile/src/lib/textDirection.test.ts | 3 + apps/mobile/src/lib/textDirection.ts | 39 ++++++---- .../ComposerPendingUserInputPanel.test.tsx | 29 ++++---- .../chat/ComposerPendingUserInputPanel.tsx | 23 ++++-- packages/shared/package.json | 4 + packages/shared/src/textDirection.test.ts | 19 +++++ packages/shared/src/textDirection.ts | 74 +++++++++++++++++++ 7 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 packages/shared/src/textDirection.test.ts create mode 100644 packages/shared/src/textDirection.ts diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts index 3dcd87fefeb8..5cedefc07fc3 100644 --- a/apps/mobile/src/lib/textDirection.test.ts +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -12,6 +12,9 @@ describe("resolveTextDirection", () => { it("resolves Hebrew and Arabic text as right-to-left", () => { expect(resolveTextDirection("הודעה בעברית")).toBe("rtl"); expect(resolveTextDirection("رسالة بالعربية")).toBe("rtl"); + expect(resolveTextDirection("React Server Components האם להשתמש בהם בפרויקט החדש שלנו?")).toBe( + "rtl", + ); }); it("resolves English and other left-to-right scripts as left-to-right", () => { diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts index dc22dfa012e0..440ba006709d 100644 --- a/apps/mobile/src/lib/textDirection.ts +++ b/apps/mobile/src/lib/textDirection.ts @@ -1,32 +1,41 @@ +import { resolveTextDirection, type TextDirection } from "@t3tools/shared/textDirection"; 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; +const MAX_MARKDOWN_PROSE_CODE_UNITS = 17_408; -function markdownProse(node: MarkdownNode): string { +function collectMarkdownProse( + node: MarkdownNode, + chunks: string[], + remainingCodeUnits: number, +): number { + if (remainingCodeUnits === 0) return 0; if (node.type === "code_inline" || node.type === "code_block" || node.type === "image") { - return ""; + return remainingCodeUnits; } if (node.type === "text") { - return (node.content ?? "").replace(GITHUB_ALERT_MARKER, ""); + const text = (node.content ?? "").slice(0, remainingCodeUnits).replace(GITHUB_ALERT_MARKER, ""); + chunks.push(text); + return remainingCodeUnits - text.length; } - return node.children?.map(markdownProse).join("") ?? ""; + for (const child of node.children ?? []) { + remainingCodeUnits = collectMarkdownProse(child, chunks, remainingCodeUnits); + if (remainingCodeUnits === 0) break; + } + return remainingCodeUnits; } -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"; +function markdownProse(node: MarkdownNode): string { + const chunks: string[] = []; + collectMarkdownProse(node, chunks, MAX_MARKDOWN_PROSE_CODE_UNITS); + return chunks.join(""); } export function resolveMarkdownNodeTextDirection(node: MarkdownNode): TextDirection { return resolveTextDirection(markdownProse(node)); } + +export { resolveTextDirection }; +export type { TextDirection }; diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx index 79943543c729..0dda534e263f 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -82,7 +82,7 @@ describe("ComposerPendingUserInputPanel", () => { { ...prompt.questions[0]!, header: "בחירת גישה", - question: "האם להשתמש ב-React Server Components עבור ה-dashboard?", + question: "React Server Components האם להשתמש בהם בפרויקט החדש שלנו?", options: [ { label: "כן, להשתמש ב-RSC", description: "מתאים ל-Next.js App Router" }, { label: "Client Components", description: "להשאיר את הממשק בצד הלקוח" }, @@ -106,16 +106,19 @@ describe("ComposerPendingUserInputPanel", () => { ); }); - const directedText = renderer!.root.findAllByProps({ dir: "auto" }); - for (const text of [ - "בחירת גישה", - "האם להשתמש ב-React Server Components עבור ה-dashboard?", - "כן, להשתמש ב-RSC", - "מתאים ל-Next.js App Router", - "Client Components", - "להשאיר את הממשק בצד הלקוח", - ]) { - expect(directedText.some((node) => node.children.includes(text))).toBe(true); + for (const [text, direction] of [ + ["בחירת גישה", "rtl"], + ["React Server Components האם להשתמש בהם בפרויקט החדש שלנו?", "rtl"], + ["כן, להשתמש ב-RSC", "rtl"], + ["מתאים ל-Next.js App Router", "rtl"], + ["Client Components", "ltr"], + ["להשאיר את הממשק בצד הלקוח", "rtl"], + ] as const) { + expect( + renderer!.root.find( + (node) => node.props.dir === direction && node.children.includes(text), + ), + ).toBeDefined(); } expect( renderer!.root.findAll( @@ -134,9 +137,9 @@ describe("ComposerPendingUserInputPanel", () => { expect( renderer!.root.find( (node) => - node.props.dir === "auto" && + node.props.dir === "rtl" && String(node.props.className).includes("truncate") && - node.children.includes("האם להשתמש ב-React Server Components עבור ה-dashboard?"), + node.children.includes("React Server Components האם להשתמש בהם בפרויקט החדש שלנו?"), ), ).toBeDefined(); } finally { diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 3c439f3946f6..50d910918e6f 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,4 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; +import { resolveTextDirection } from "@t3tools/shared/textDirection"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { type PendingUserInput } from "../../session-logic"; import { @@ -187,11 +188,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( > - + {activeQuestion.header} {isCollapsed ? ( - + {activeQuestion.question} ) : null} @@ -229,7 +236,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( -

+

{activeQuestion.question}

{activeQuestion.multiSelect ? ( @@ -256,11 +266,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const content = ( <>
- + {option.label} {option.description && option.description !== option.label ? ( - + {option.description} ) : null} diff --git a/packages/shared/package.json b/packages/shared/package.json index faf0d6f2a0f5..1c0b14f03373 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./textDirection": { + "types": "./src/textDirection.ts", + "import": "./src/textDirection.ts" + }, "./delimitedPreview": { "types": "./src/delimitedPreview.ts", "import": "./src/delimitedPreview.ts" diff --git a/packages/shared/src/textDirection.test.ts b/packages/shared/src/textDirection.test.ts new file mode 100644 index 000000000000..8af3fe3be11e --- /dev/null +++ b/packages/shared/src/textDirection.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveTextDirection } from "./textDirection.js"; + +describe("resolveTextDirection", () => { + it.each([ + ["العربية مع English terms", "rtl"], + ["React Server Components האם להשתמש בהם בפרויקט החדש שלנו?", "rtl"], + ["English text with one كلمة", "ltr"], + ["one two three four עברית עברית עברית עברית", "ltr"], + ["👋 123...", "ltr"], + ] as const)("resolves %s as %s", (text, direction) => { + expect(resolveTextDirection(text)).toBe(direction); + }); + + it("bounds neutral-prefix inspection", () => { + expect(resolveTextDirection(`${".".repeat(8_192)}العربية`)).toBe("ltr"); + }); +}); diff --git a/packages/shared/src/textDirection.ts b/packages/shared/src/textDirection.ts new file mode 100644 index 000000000000..36b31db36cf8 --- /dev/null +++ b/packages/shared/src/textDirection.ts @@ -0,0 +1,74 @@ +export type TextDirection = "ltr" | "rtl"; + +const LETTER_CHARACTER = /^\p{Letter}$/u; +const MARK_CHARACTER = /^\p{Mark}$/u; +const RTL_SCRIPT_CHARACTER = + /^[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufeff\u{10800}-\u{10fff}\u{1e800}-\u{1eeff}]$/u; +// ponytail: bound streaming-render work; raise these only for real text that exceeds the sample. +const MAX_FIRST_LETTER_CODE_POINTS = 8_192; +const MAX_DIRECTION_CODE_POINTS = 512; +const MAX_DIRECTION_WORDS = 32; +const MAX_WORD_SCORE = 4; +const MAX_LEADING_LTR_WORDS = 3; +const MIN_RTL_SCORE = 8; + +export function resolveTextDirection(text: string): TextDirection { + let searchedCodePoints = 0; + let inspectedCodePoints = 0; + let fallbackStarted = false; + let wordCount = 0; + let rtlScore = 0; + let totalScore = 0; + let wordRtlLetters = 0; + let wordLtrLetters = 0; + let wordScore = 0; + let earlyRtlWordSeen = false; + + for (const character of text) { + if (!fallbackStarted) { + if (searchedCodePoints === MAX_FIRST_LETTER_CODE_POINTS) return "ltr"; + searchedCodePoints += 1; + if (!LETTER_CHARACTER.test(character)) continue; + if (RTL_SCRIPT_CHARACTER.test(character)) return "rtl"; + fallbackStarted = true; + } + + if (inspectedCodePoints === MAX_DIRECTION_CODE_POINTS || wordCount === MAX_DIRECTION_WORDS) + break; + inspectedCodePoints += 1; + + if (LETTER_CHARACTER.test(character)) { + if (RTL_SCRIPT_CHARACTER.test(character)) wordRtlLetters += 1; + else wordLtrLetters += 1; + if (wordScore < MAX_WORD_SCORE) wordScore += 1; + continue; + } + + if (MARK_CHARACTER.test(character) && wordScore !== 0) continue; + if (wordScore === 0) continue; + + wordCount += 1; + totalScore += wordScore; + if (wordRtlLetters > wordLtrLetters) { + rtlScore += wordScore; + if (wordCount <= MAX_LEADING_LTR_WORDS + 1) earlyRtlWordSeen = true; + } + if (wordCount === MAX_LEADING_LTR_WORDS + 1 && !earlyRtlWordSeen) return "ltr"; + + wordRtlLetters = 0; + wordLtrLetters = 0; + wordScore = 0; + } + + if (wordScore !== 0 && wordCount < MAX_DIRECTION_WORDS) { + totalScore += wordScore; + if (wordRtlLetters > wordLtrLetters) { + rtlScore += wordScore; + if (wordCount < MAX_LEADING_LTR_WORDS + 1) earlyRtlWordSeen = true; + } + } + + return earlyRtlWordSeen && rtlScore >= MIN_RTL_SCORE && rtlScore * 5 >= totalScore * 3 + ? "rtl" + : "ltr"; +}