diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index a8c82552f9bb..4f1342802ed3 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,18 @@ 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("\u{1E900}\u{1E92F} adlam")).toBe("rtl"); // astral RTL block + 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 13024a7516ff..7e6df12072e8 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,31 @@ function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } -function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { +// 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; + +// 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,16 +531,23 @@ function MarkdownTable({ children, ...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} +
+
+
)["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} + {/* 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}
@@ -1672,9 +1710,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 = @@ -1844,8 +1893,8 @@ function ChatMarkdown({ } return ; }, - 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", diff --git a/apps/web/src/index.css b/apps/web/src/index.css index f69adb9cf08e..1eadb1a99332 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2104,6 +2104,23 @@ 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; +} + +.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 +2177,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 +2188,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 +2222,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 +2247,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 +2373,7 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + text-align: start; } .chat-markdown thead th {