Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
83 changes: 66 additions & 17 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { DirectionProvider, type TextDirection } from "@base-ui/react/direction-provider";
import { useAtomValue } from "@effect/atom-react";
import {
CheckIcon,
Expand Down Expand Up @@ -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";
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

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<React.ComponentProps<"table">, "dir"> & { dir?: TextDirection }) {
const containerRef = useRef<HTMLDivElement | null>(null);
const tableRef = useRef<HTMLTableElement | null>(null);
const [expanded, setExpanded] = useState(readInitialWordWrapSetting);
Expand Down Expand Up @@ -506,16 +531,23 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) {
className="chat-markdown-table-container"
data-expanded={expanded ? "true" : "false"}
>
<ScrollArea
chainVerticalScroll
scrollFade
hideScrollbars
className="w-full max-w-full rounded-none"
>
<table ref={tableRef} {...props}>
{children}
</table>
</ScrollArea>
{/* 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`. */}
<DirectionProvider direction={dir}>
<ScrollArea
dir={dir}
chainVerticalScroll
scrollFade
hideScrollbars
className="w-full max-w-full rounded-none"
>
<table ref={tableRef} {...props}>
{children}
</table>
</ScrollArea>
</DirectionProvider>
<div className="mt-0.5 flex items-center justify-between select-none">
<Tooltip>
<TooltipTrigger
Expand Down Expand Up @@ -1652,15 +1684,21 @@ function ChatMarkdown({
String((props as Record<string, unknown>)["data-alert"] ?? "")
];
if (!alert) {
return <blockquote {...props}>{children}</blockquote>;
return (
<blockquote dir="auto" {...props}>
{children}
</blockquote>
);
}
// Not a <blockquote>: the stylesheet mutes those, and an alert's body is ordinary
// text under a colored title — which is how the host renders it.
return (
<div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>
<div role="note" dir="auto" className={cn("my-1 border-s-2 ps-3", alert.borderClassName)}>
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
<p className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}>
<alert.Icon aria-hidden className="size-3.5 shrink-0" />
{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. */}
<span dir="ltr">{alert.label}</span>
</p>
{children}
</div>
Expand All @@ -1672,9 +1710,20 @@ function ChatMarkdown({
.length ?? 0;
const gutterStyle = orderedListGutterStyle(itemCount, start);
return (
<ol {...props} start={start} style={gutterStyle ? { ...style, ...gutterStyle } : style} />
<ol
dir="auto"
{...props}
start={start}
style={gutterStyle ? { ...style, ...gutterStyle } : style}
/>
);
},
// `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 <ul dir="auto" {...props} />;
},
li({ node, children, ...props }) {
const listItemStart = node?.position?.start.offset;
const markerOffset =
Expand Down Expand Up @@ -1844,8 +1893,8 @@ function ChatMarkdown({
}
return <ChatMarkdownImageFallback alt={altText} />;
},
table({ node: _node, ...props }) {
return <MarkdownTable {...props} />;
table({ node, dir: _dir, ...props }) {
return <MarkdownTable dir={firstStrongDirection(hastTextContent(node))} {...props} />;
},
details({ node: _node, children, open: detailsOpen }) {
return <MarkdownDetails open={detailsOpen}>{children}</MarkdownDetails>;
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 24 additions & 6 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

.chat-markdown pre,
.chat-markdown code,
.chat-markdown .chat-markdown-codeblock {
direction: ltr;
unicode-bidi: isolate;
}

.chat-markdown > :first-child {
margin-top: 0;
}
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading