From 24b8c7003cf299de3ea24fc3b8293457b6cbbf49 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Wed, 19 Aug 2026 20:04:13 +0300 Subject: [PATCH 1/5] fix(web): render Hebrew/Arabic chat markdown right-to-left Every leaf block in .chat-markdown resolves its own base direction from its first strong character (unicode-bidi: plaintext + text-align: start), and lists / blockquotes / tables get dir="auto" so markers, the quote bar and column order land on the content's side. Physical paddings/borders on those containers become logical. Code stays LTR. No global flip: a mixed English/Hebrew message renders block by block. --- apps/web/src/components/ChatMarkdown.tsx | 23 +++++++++++++++--- apps/web/src/index.css | 31 +++++++++++++++++++----- 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 13024a7516ff..3fbf34e21054 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1652,12 +1652,16 @@ function ChatMarkdown({ String((props as Record)["data-alert"] ?? "") ]; if (!alert) { - return
{children}
; + return ( +
+ {children} +
+ ); } // 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} @@ -1672,9 +1676,20 @@ function ChatMarkdown({ .length ?? 0; const gutterStyle = orderedListGutterStyle(itemCount, start); return ( -

    +
      ); }, + // `dir="auto"`: the browser picks each list's / quote's / table's base direction from its + // first strong character, so Hebrew/Arabic content gets its markers, bar and column order + // on the right while English blocks stay LTR (paired with the bidi rules in index.css). + ul({ node: _node, ...props }) { + return
        ; + }, li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = @@ -1845,7 +1860,7 @@ function ChatMarkdown({ return ; }, table({ node: _node, ...props }) { - return ; + return ; }, details({ node: _node, children, open: detailsOpen }) { return {children}; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f69adb9cf08e..e031742ee41d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2104,6 +2104,24 @@ code { /* Chat markdown rendering */ +/* Bidi: Hebrew/Arabic messages. Every leaf block resolves its own base direction + from its first strong character (`plaintext`), so a Hebrew paragraph reads and + aligns right-to-left while the English block above it stays put — no global + flip, mixed-language threads just work. Containers that carry a directional + decoration (list markers, blockquote bar, table column order) get `dir="auto"` + in ChatMarkdown.tsx and use logical properties below. Code stays LTR. */ +.chat-markdown :is(p, li, h1, h2, h3, h4, h5, h6, td, th, dt, dd) { + unicode-bidi: plaintext; + text-align: start; +} + +.chat-markdown pre, +.chat-markdown code, +.chat-markdown .chat-markdown-codeblock { + direction: ltr; + unicode-bidi: isolate; +} + .chat-markdown > :first-child { margin-top: 0; } @@ -2160,7 +2178,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; } @@ -2171,7 +2189,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; } @@ -2205,7 +2223,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-block: 0 0.15em; + margin-inline: calc(-1 * var(--list-gutter, 1.25rem)) 0.35em; vertical-align: middle; } @@ -2229,8 +2248,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); } @@ -2355,7 +2374,7 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + text-align: start; } .chat-markdown thead th { From e8d78840f2ead979e3a9f93a20ae9ac75b245575 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Wed, 19 Aug 2026 20:51:59 +0300 Subject: [PATCH 2/5] fix(web): resolve bidi on alert body and table scroll viewport - GitHub alerts: the injected English label was the first strong character, so dir="auto" on the container never resolved RTL. Give the label dir="ltr" so the auto algorithm skips it and the body decides the side of the bar/padding. - Tables: put dir on the ScrollArea root rather than only the , so an overflowing RTL table opens scrolled to its first (rightmost) column. --- apps/web/src/components/ChatMarkdown.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3fbf34e21054..8434148b4aed 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -431,7 +431,7 @@ function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } -function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { +function MarkdownTable({ children, dir, ...props }: React.ComponentProps<"table">) { const containerRef = useRef(null); const tableRef = useRef(null); const [expanded, setExpanded] = useState(readInitialWordWrapSetting); @@ -507,6 +507,9 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { data-expanded={expanded ? "true" : "false"} > -

        + {/* dir="ltr" on the label excludes it from the container's dir="auto" resolution + (elements with their own dir are skipped), so the body text decides the side. */} +

        {alert.label}

        From b28261eebb1c539a384181d718494cedc2240f13 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Wed, 19 Aug 2026 21:19:11 +0300 Subject: [PATCH 3/5] fix(web): give RTL tables a concrete direction Base UI can follow Resolve the table's direction from its text (first strong letter) instead of dir="auto", pass it to the ScrollArea and to Base UI's DirectionProvider so the viewport's scroll-edge math matches the rendered direction, and swap the scroll-fade mask sides under rtl since Base UI's overflow vars are logical while the mask utilities are physical. --- apps/web/src/components/ChatMarkdown.test.tsx | 16 ++++- apps/web/src/components/ChatMarkdown.tsx | 59 ++++++++++++++----- apps/web/src/components/ui/scroll-area.tsx | 4 ++ 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index a8c82552f9bb..c7e76c93bda4 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { orderedListGutterStyle } from "./ChatMarkdown"; +import { firstStrongDirection, orderedListGutterStyle } from "./ChatMarkdown"; describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -42,3 +42,17 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); }); }); + +describe("firstStrongDirection", () => { + it("reads the first letter, skipping neutral digits and punctuation", () => { + expect(firstStrongDirection("רכיב | סטטוס")).toBe("rtl"); + expect(firstStrongDirection("1. (שלב) ראשון")).toBe("rtl"); + expect(firstStrongDirection("Component | Status")).toBe("ltr"); + expect(firstStrongDirection("42 — Next.js then עברית")).toBe("ltr"); + }); + + it("falls back to ltr when there is no strong character", () => { + expect(firstStrongDirection("")).toBe("ltr"); + expect(firstStrongDirection("123 | 456")).toBe("ltr"); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8434148b4aed..071ccdcbf472 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,3 +1,4 @@ +import { DirectionProvider, type TextDirection } from "@base-ui/react/direction-provider"; import { useAtomValue } from "@effect/atom-react"; import { CheckIcon, @@ -431,7 +432,29 @@ function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } -function MarkdownTable({ children, dir, ...props }: React.ComponentProps<"table">) { +// Strong-RTL code points (Hebrew, Arabic and friends, incl. presentation forms). +const STRONG_RTL_CHAR = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/u; +// First letter decides (UBA P2/P3): digits, punctuation and symbols are neutral. +const FIRST_LETTER = /\p{L}/u; + +// The direction a block of text renders in — what `dir="auto"` would resolve. +export function firstStrongDirection(text: string): TextDirection { + const letter = FIRST_LETTER.exec(text)?.[0]; + return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; +} + +function hastTextContent(node: unknown): string { + if (!node || typeof node !== "object") return ""; + const n = node as { type?: string; value?: string; children?: unknown[] }; + if (n.type === "text") return n.value ?? ""; + return (n.children ?? []).map(hastTextContent).join(""); +} + +function MarkdownTable({ + children, + dir = "ltr", + ...props +}: Omit, "dir"> & { dir?: TextDirection }) { const containerRef = useRef(null); const tableRef = useRef(null); const [expanded, setExpanded] = useState(readInitialWordWrapSetting); @@ -506,19 +529,23 @@ function MarkdownTable({ children, dir, ...props }: React.ComponentProps<"table" className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - -
        - {children} -
        - + {/* A concrete direction on the scroll viewport (not just the table) so an + overflowing RTL table opens at its first, rightmost column — and the same + value fed to Base UI, whose scroll-fade math reads its DirectionProvider + rather than the DOM `dir`. */} + + + + {children} +
        +
        +
        ; }, - table({ node: _node, ...props }) { - return ; + table({ node, dir: _dir, ...props }) { + return ; }, details({ node: _node, children, open: detailsOpen }) { return {children}; diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index bfc10825b460..23c71eea3174 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -46,6 +46,10 @@ function ScrollArea({ chainVerticalScroll && "overscroll-y-auto", scrollFade && "scroll-p-[var(--fade-size)] mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]", + // Base UI's overflow-x vars are logical (start = the scroll-start edge), while the + // mask utilities are physical — under dir="rtl" the start edge is the right one. + scrollFade && + "rtl:mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] rtl:mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))]", scrollbarGutter && "scrollbar-gutter-stable", hideScrollbars && "[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", From 8943fd96e180f3c1b3c2adfb232df0d178359151 Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Wed, 19 Aug 2026 21:29:32 +0300 Subject: [PATCH 4/5] fix(web): keep alert title row in the body's direction; cover astral RTL scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dir="ltr" now sits on the alert label text only, not the flex title row, so in a Hebrew alert the icon + label follow the bar and body to the right. - firstStrongDirection recognises the astral RTL blocks (U+10800–U+10FFF, U+1E800–U+1EFFF: Phoenician … Adlam). --- apps/web/src/components/ChatMarkdown.test.tsx | 1 + apps/web/src/components/ChatMarkdown.tsx | 17 ++++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index c7e76c93bda4..4f1342802ed3 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -47,6 +47,7 @@ describe("firstStrongDirection", () => { it("reads the first letter, skipping neutral digits and punctuation", () => { expect(firstStrongDirection("רכיב | סטטוס")).toBe("rtl"); expect(firstStrongDirection("1. (שלב) ראשון")).toBe("rtl"); + expect(firstStrongDirection("\u{1E900}\u{1E92F} adlam")).toBe("rtl"); // astral RTL block expect(firstStrongDirection("Component | Status")).toBe("ltr"); expect(firstStrongDirection("42 — Next.js then עברית")).toBe("ltr"); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 071ccdcbf472..7e6df12072e8 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -432,8 +432,10 @@ function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } -// Strong-RTL code points (Hebrew, Arabic and friends, incl. presentation forms). -const STRONG_RTL_CHAR = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/u; +// Strong-RTL code points: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic and +// their extensions/presentation forms, plus the astral RTL blocks (Phoenician … Adlam). +const STRONG_RTL_CHAR = + /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; // First letter decides (UBA P2/P3): digits, punctuation and symbols are neutral. const FIRST_LETTER = /\p{L}/u; @@ -1692,14 +1694,11 @@ function ChatMarkdown({ // text under a colored title — which is how the host renders it. return (
        - {/* dir="ltr" on the label excludes it from the container's dir="auto" resolution - (elements with their own dir are skipped), so the body text decides the side. */} -

        +

        - {alert.label} + {/* dir="ltr" on the label text only (not the row) keeps it out of the container's + dir="auto" resolution, so the body decides the side and the row follows it. */} + {alert.label}

        {children}
        From 94394cd334f8a7673e9afa0e819b93759b6103cd Mon Sep 17 00:00:00 2001 From: Asaf Benatia Date: Wed, 19 Aug 2026 21:41:21 +0300 Subject: [PATCH 5/5] fix(web): drop redundant text-align: start on bidi leaf blocks start is the initial value, so the declaration only ever overrode the HTML align presentational hint that raw-HTML surfaces (PR bodies, README previews) rely on. unicode-bidi: plaintext alone aligns each block to its own start edge. --- apps/web/src/index.css | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index e031742ee41d..1eadb1a99332 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2112,7 +2112,6 @@ code { in ChatMarkdown.tsx and use logical properties below. Code stays LTR. */ .chat-markdown :is(p, li, h1, h2, h3, h4, h5, h6, td, th, dt, dd) { unicode-bidi: plaintext; - text-align: start; } .chat-markdown pre,