From 0d66eabb102aceaf863bbb8cbd009014ad88c111 Mon Sep 17 00:00:00 2001
From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com>
Date: Sat, 15 Aug 2026 20:27:25 +0000
Subject: [PATCH 01/31] fix(web): read Arabic and Hebrew messages in the right
direction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A message renders under the app's direction rather than its own, so Arabic
prose comes out with its trailing punctuation on the wrong end, inline code and
file paths displaced inside the sentence, and list bullets and quote bars on the
side opposite the text they belong to.
Each block of message markdown now carries dir="auto", so the browser takes that
block's base direction from its own first strong character — one Arabic
paragraph and one English paragraph in the same message each read correctly.
Code and tables opt out and stay left-to-right, since identifiers, paths, and
column order are not prose. Only the outermost block of a run is marked, because
dir="auto" skips descendants that carry their own dir: marking a list and its
items both would leave the list with no text to judge and paint its bullets into
a gutter that had moved. The list, quote, and task-list gutters in the
stylesheet become logical so they follow the marker.
Thread titles and project names get the same treatment: they are generated from
the user's own prompt, and truncating them needs the ellipsis on the correct end.
Written by Claude Opus 5 in Claude Code.
---
apps/web/src/components/ChatMarkdown.test.tsx | 46 +++++++++++++-
apps/web/src/components/ChatMarkdown.tsx | 63 +++++++++++++++++++
apps/web/src/components/LegacySidebar.tsx | 7 ++-
apps/web/src/components/Sidebar.tsx | 22 +++++--
apps/web/src/components/chat/ChatHeader.tsx | 18 ++++--
.../components/chat/MessagesTimeline.test.tsx | 4 +-
.../src/components/chat/MessagesTimeline.tsx | 12 +++-
apps/web/src/index.css | 15 +++--
8 files changed, 166 insertions(+), 21 deletions(-)
diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index 9499ee5a6915..c2b4004a7b25 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -1,6 +1,7 @@
+import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
-import { orderedListGutterStyle } from "./ChatMarkdown";
+import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";
describe("orderedListGutterStyle", () => {
it("leaves the default gutter alone for single-digit lists", () => {
@@ -34,3 +35,46 @@ describe("orderedListGutterStyle", () => {
expect(orderedListGutterStyle(0, undefined)).toBeUndefined();
});
});
+
+describe("chat markdown text direction", () => {
+ function render(text: string) {
+ return renderToStaticMarkup(
English first.
'); + expect(html).toContain('مرحبا بالعالم.
'); + }); + + it("marks headings, lists, and quotes so their markers follow the text", () => { + const html = render("# عنوان\n\n- عنصر\n\n> اقتباس"); + expect(html).toContain(''); + }); + + it("marks only the outermost block, so a container still sees its own text", () => { + // A nested `dir` would be skipped when the browser resolves the outer + // `dir="auto"`, leaving the list LTR and its bullets in the wrong gutter. + const html = render("- عنصر\n\n> اقتباس"); + expect(html).toContain("- "); + expect(html).not.toContain("
- \n
'); + }); + + it("pins code left-to-right so an Arabic comment cannot reorder a snippet", () => { + const html = render("`git status` وأيضا\n\n```sh\n# تعليق\ngit status\n```"); + // The paragraph around it still reads right-to-left; only the code opts out. + expect(html).toContain('
'); + expect(html).toContain('
git status'); + expect(html).toContain('{ + const html = render("| اسم | value |\n| --- | --- |\n| قيمة | 1 |"); + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain(' '); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c4548540e2ce..26b963b48a0e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -195,6 +195,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkTagInlineCode, + remarkTextDirection, ] satisfies NonNullable ; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -204,6 +205,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkBreaks, remarkPreserveCodeMeta, remarkTagInlineCode, + remarkTextDirection, ] satisfies NonNullable ; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -340,6 +342,64 @@ function remarkTagInlineCode() { }; } +/** + * Message prose belongs to whoever wrote it, so its direction is a property of + * the text and not of the app: `dir="auto"` makes the browser read each block's + * base direction off that block's own first strong character, which is what + * puts an Arabic sentence's trailing punctuation and its list markers on the + * right side without touching the English block above it. + * + * Code and tables opt out and stay LTR. Their shape is not prose — identifiers, + * paths, and column order read the same in every locale, and letting an Arabic + * comment flip a snippet would misreport what the agent actually wrote. + */ +const AUTO_DIRECTION_NODE_TYPES = new Set([ + "blockquote", + "heading", + "list", + "paragraph", + "tableCell", +]); +const LTR_DIRECTION_NODE_TYPES = new Set(["code", "inlineCode", "table"]); + +function setDirection(node: MarkdownAstNode, dir: "auto" | "ltr") { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dir, + }, + }; +} + +function remarkTextDirection() { + return (tree: MarkdownAstNode) => { + // `dir="auto"` reads the first strong character of an element's *own* text + // and skips any descendant that carries its own `dir`. So only the outermost + // block of a run gets marked: marking a list and its items both would leave + // the list itself with no text to judge, fall back to LTR, and paint the + // bullets of an RTL item into a gutter that is no longer on that side. + const visit = (node: MarkdownAstNode, insideAutoBlock: boolean) => { + const type = node.type ?? ""; + if (LTR_DIRECTION_NODE_TYPES.has(type)) { + setDirection(node, "ltr"); + // A pinned table is not an `auto` ancestor, so its cells are free to + // pick their own direction while the column order stays put. + node.children?.forEach((child) => visit(child, false)); + return; + } + + const isAutoBlock = !insideAutoBlock && AUTO_DIRECTION_NODE_TYPES.has(type); + if (isAutoBlock) { + setDirection(node, "auto"); + } + node.children?.forEach((child) => visit(child, insideAutoBlock || isAutoBlock)); + }; + + visit(tree, false); + }; +} + function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -671,6 +731,9 @@ function MarkdownCodeBlock({ return ( @@ -737,7 +738,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } /> -+ {thread.title} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 31a73d133075..c650bf887fcf 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -290,7 +290,10 @@ function SidebarThreadTooltip({ className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0" >-+{thread.title}@@ -302,7 +305,9 @@ function SidebarThreadTooltip({ faviconPath={projectFaviconPath} className="size-3 shrink-0 stroke-muted-foreground" /> -) : null} {environmentLabel ? ( @@ -533,7 +538,10 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { faviconPath={props.projectFaviconPath} className="size-4 shrink-0" /> - + {props.projectTitle} @@ -1124,6 +1132,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> ) : ( {props.projectTitle ? ( - {thread.title} + + {thread.title} + {threadTimeLabel(thread)} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index d032b16a186b..d3eea99763ad 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -253,7 +253,9 @@ export const ChatHeader = memo(function ChatHeader({ faviconPath={activeProjectFaviconPath} className="size-3.5" /> - {activeProjectName} + + {activeProjectName} +{projectTitle}++ {projectTitle} +New thread in {activeProjectName} @@ -289,24 +291,30 @@ export const ChatHeader = memo(function ChatHeader({ /> } > -{activeThreadTitle}
++ {activeThreadTitle} +
- {activeThreadTitle} ++ {activeThreadTitle} + ) : ()} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 617ee0b80d1c..1945a605547d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -520,7 +520,9 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain(' + {activeThreadTitle}
} /> -{activeThreadTitle} ++ {activeThreadTitle} + <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/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c90aa771f8d1..9ff416f40225 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1773,7 +1773,10 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -+{comment.text.length > 0 && ( -{inlineNodes}); @@ -1812,7 +1815,10 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -+{inlineNodes}); @@ -1854,7 +1860,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte+)} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fea03489b7fa..c1758876c5fe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1640,7 +1640,9 @@ 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; + /* Logical, because a list whose text is Arabic or Hebrew carries dir="auto" + and paints its markers on the right — the gutter has to move with them. */ + padding-inline-start: 1.25rem; list-style-type: disc; } @@ -1651,7 +1653,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 +1683,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 +1708,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 +1723,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 { From 8d90e3abf69ef5bbf365a444f2321414ff8491bd Mon Sep 17 00:00:00 2001 From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:22:56 +0000 Subject: [PATCH 02/31] fix(web): carry direction into alerts, file chips, and rename inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the same concern. A GitHub alert is not rendered as a blockquote — its renderer builds a titled callout from scratch — so claiming the blockquote as the marked block stranded the body: the dir never reached the callout, and the paragraphs inside it were skipped as already-covered. Alert blockquotes are no longer claimed, so their paragraphs carry their own direction under LTR chrome, and that chrome's gutter becomes logical. A file path is an identifier, but the code renderer swaps a chip in for the `` it replaces, so the pin was lost exactly where the PR claimed to fix it. The chip carries it now. The terminal-context wrapper drops its dir: the chips always precede the message text, so it could only ever resolve from a chip label, while the markdown below already picks its own direction per block. Thread-title rename inputs get dir="auto" so a title does not flip direction the moment it is edited. Written by Claude Opus 5 in Claude Code. --- apps/web/src/components/ChatMarkdown.test.tsx | 15 +++++++++++++++ apps/web/src/components/ChatMarkdown.tsx | 18 ++++++++++++++++-- apps/web/src/components/LegacySidebar.tsx | 1 + apps/web/src/components/Sidebar.tsx | 1 + .../src/components/chat/MessagesTimeline.tsx | 8 ++++---- 5 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index c2b4004a7b25..c5cf63efeecf 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -71,6 +71,21 @@ describe("chat markdown text direction", () => { expect(html).toContain('{ + // The alert renderer builds its own element, so the blockquote cannot be the + // marked block — the body paragraphs have to carry the direction instead. + const html = render("> [!NOTE]\n> مرحبا بالعالم."); + expect(html).toContain('مرحبا بالعالم.
'); + expect(html).not.toContain("{ + // The `code` renderer swaps the chip in for the `` it + // replaces, so a path in an Arabic sentence keeps its own reading order. + const html = render("عدّل `src/main.ts` من فضلك."); + expect(html).toContain(' { const html = render("| اسم | value |\n| --- | --- |\n| قيمة | 1 |"); expect(html).toContain(''); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 26b963b48a0e..8cd688647111 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -379,6 +379,10 @@ function remarkTextDirection() { // block of a run gets marked: marking a list and its items both would leave // the list itself with no text to judge, fall back to LTR, and paint the // bullets of an RTL item into a gutter that is no longer on that side. + // + // The cost is that one list reads in one direction. A list that mixes an + // Arabic item with an English one takes the direction of its first item, + // which is the trade for markers that stay next to the text they label. const visit = (node: MarkdownAstNode, insideAutoBlock: boolean) => { const type = node.type ?? ""; if (LTR_DIRECTION_NODE_TYPES.has(type)) { @@ -389,7 +393,13 @@ function remarkTextDirection() { return; } - const isAutoBlock = !insideAutoBlock && AUTO_DIRECTION_NODE_TYPES.has(type); + // A GitHub alert is rendered as a titled callout rather than a quote, and + // its own renderer builds that chrome from scratch. Claiming the block + // here would strand its body: the `dir` never reaches the callout, and the + // paragraphs inside it would have been skipped as already-covered. + const isAlertBlockquote = type === "blockquote" && node.data?.hProperties?.dataAlert != null; + const isAutoBlock = + !insideAutoBlock && !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type); if (isAutoBlock) { setDirection(node, "auto"); } @@ -1364,6 +1374,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
` it replaces, so the pin has to live here too. + dir="ltr" href={href} className={cn(CHAT_FILE_TAG_CHIP_CLASS_NAME, MARKDOWN_FILE_LINK_CLASS_NAME, className)} data-markdown-copy={copyMarkdown} @@ -1631,7 +1645,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 ( -+From c430d4b32f7d0c600c3d5e6718e615894bb8436f Mon Sep 17 00:00:00 2001 From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:00:35 +0000 Subject: [PATCH 06/31] fix(web): narrow the palette to the one string that is message text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backing out my own overreach. The palette's title span is shared with the file picker and the add-project browser, where the title is a basename or directory name — an identifier, which this PR otherwise pins left-to-right. Marking it auto could reorder an Arabic filename against its Latin extension, so those titles go back to inheriting the app's direction. The search excerpt keeps the treatment. It is a message quoted back to the user, which is what this PR is about, and it needs its own wrapper because the "You:"/"Agent:" label in front of it fixes the line itself left-to-right. Written by Claude Opus 5 in Claude Code. --- .../src/components/CommandPaletteResults.tsx | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 69f8e4d0e0fc..97a2b9856997 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -74,7 +74,9 @@ function ThreadContentMatch(props: { {isUser ? "You:" : "Agent:"} {" "} -
{alert.label} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 5eb7b0146b8f..aeca799540ed 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -717,6 +717,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr {renamingThreadKey === threadKey ? ( onRenameTitleChange(event.target.value)} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9ff416f40225..0ed366de4a20 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1815,10 +1815,10 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( - + // No `dir` here: the terminal-context chips always come first, so the + // wrapper could only ever resolve from their label. The message text below + // them is markdown, and each of its blocks picks its own direction. +-{inlineNodes}); From adcffb402a9198071f807a14af49597ac8812dc7 Mon Sep 17 00:00:00 2001 From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:36:21 +0000 Subject: [PATCH 03/31] fix(web): align table cells to their own text The last physical inline property in the markdown stylesheet. Cells carry dir="auto" now, so an Arabic cell resolves right-to-left for ordering while `text-align: left` still pinned it to the cell's left edge. `start` follows the cell's own direction and is identical for left-to-right content; the table stays pinned so the columns keep their source order. Written by Claude Opus 5 in Claude Code. --- apps/web/src/index.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c1758876c5fe..59b720af8313 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1835,7 +1835,9 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + /* Logical: the table is pinned LTR so its columns keep their source order, + but each cell carries dir="auto" and aligns to its own text. */ + text-align: start; } .chat-markdown thead th { From ca9169cab12826709783e665bd32f680d8e34603 Mon Sep 17 00:00:00 2001 From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:42:11 +0000 Subject: [PATCH 04/31] fix(web): keep the header rename input on the title's own direction The third of three rename inputs for the same title. The sidebar rows got dir="auto" but the header did not, so starting a rename there was the one place an Arabic title flipped to left-to-right under the caret and back again on commit. Written by Claude Opus 5 in Claude Code. --- apps/web/src/components/chat/ChatHeader.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index d3eea99763ad..38008344477e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -267,6 +267,7 @@ export const ChatHeader = memo(function ChatHeader({ {renamingTitle !== null ? ( Date: Sat, 15 Aug 2026 21:54:50 +0000 Subject: [PATCH 05/31] fix(web): finish the truncated-text sweep for draft previews and the palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft row's second line is the prompt the user actually typed, so it needed the treatment its own project label had already received. Swept the rest of the class rather than wait to be told again: the command palette lists the same thread and project titles, and it is the other entry point to them. Command names and file paths are unaffected — they are already left-to-right, and dir="auto" resolves them the same way. Written by Claude Opus 5 in Claude Code. --- .../web/src/components/CommandPaletteResults.tsx | 16 ++++++++++++---- apps/web/src/components/Sidebar.tsx | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index bbdbc28b0609..69f8e4d0e0fc 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -136,7 +136,9 @@ function DisabledCommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + {props.item.threadContentMatch ? (@@ -150,7 +152,9 @@ function DisabledCommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + )} {props.item.titleTrailingContent} @@ -187,7 +191,9 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + {props.item.threadContentMatch ? ( @@ -201,7 +207,9 @@ function CommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + )} {props.item.titleTrailingContent} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e7160df8d075..f08e1bade7c5 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -562,7 +562,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { {preview}++ {preview} ++ + + ); } @@ -136,9 +138,7 @@ function DisabledCommandPaletteResultRow(props: { {props.item.titleLeadingContent} - - {props.item.title} - + {props.item.title} {props.item.threadContentMatch ? ( @@ -152,9 +152,7 @@ function DisabledCommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - - {props.item.title} - + {props.item.title} )} {props.item.titleTrailingContent} @@ -191,9 +189,7 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} - - {props.item.title} - + {props.item.title} {props.item.threadContentMatch ? ( @@ -207,9 +203,7 @@ function CommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - - {props.item.title} - + {props.item.title} )} {props.item.titleTrailingContent} From 0129d2c3964d1724910f8e62d14551199cbfe46f Mon Sep 17 00:00:00 2001 From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:10:32 +0000 Subject: [PATCH 07/31] fix(web): let a palette item say whether its title is prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction here is a property of the item, not of the row. The thread and project builders know they are emitting a title derived from someone's prompt; the file picker and the browse rows know they are emitting a basename. So the builders carry the decision, and the truncating span applies it — that span has to be the one, since it owns the ellipsis. This restores direction-aware thread and project titles in the palette, which the previous commit dropped to protect filenames, without marking identifiers. Written by Claude Opus 5 in Claude Code. --- apps/web/src/components/CommandPalette.logic.ts | 8 ++++++++ .../web/src/components/CommandPaletteResults.tsx | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 1fddb4f92f4a..37c629ce47da 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -89,6 +89,12 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray ; readonly title: ReactNode; + /** + * `"auto"` for titles that are prose the user or an agent wrote, so they read + * in their own direction. Left unset for the rest: this list also holds + * command names and file paths, and an identifier keeps the app's direction. + */ + readonly titleDir?: "auto"; readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; @@ -158,6 +164,7 @@ export function buildProjectActionItems(input: { value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], title: project.title, + titleDir: "auto", description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), @@ -239,6 +246,7 @@ export function buildThreadActionItems {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + {props.item.threadContentMatch ? ( @@ -152,7 +154,9 @@ function DisabledCommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + )} {props.item.titleTrailingContent} @@ -189,7 +193,9 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + {props.item.threadContentMatch ? ( @@ -203,7 +209,9 @@ function CommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + )} {props.item.titleTrailingContent} From d8e825ac5ec5f404c2ff89f07a69bf732d11359c Mon Sep 17 00:00:00 2001 From: Asim M Al Twijry <3624441+AsimNet@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:19:49 +0000 Subject: [PATCH 08/31] fix(web): let the sidebar thread tooltip align to its own text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The popup pinned text-align to the physical left, which inherited into the title and project name it stacks — both of which resolve their own direction now, so an Arabic title read right-to-left while sitting against the left edge. The two sibling tooltips set no alignment at all and were already correct. Logical alignment renders identically for the left-to-right metadata rows. Written by Claude Opus 5 in Claude Code. --- apps/web/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f08e1bade7c5..a3c9ff29af82 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -287,7 +287,7 @@ function SidebarThreadTooltip({ align="start" sideOffset={4} variant="glass" - className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0" + className="max-w-80 text-start whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0" > Date: Wed, 19 Aug 2026 20:04:13 +0300 Subject: [PATCH 09/31] 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 | 33 +++++++++++++++++++----- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c4548540e2ce..e19a32c3116e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1563,12 +1563,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} @@ -1583,9 +1587,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 = @@ -1730,7 +1745,7 @@ function ChatMarkdown({ ); }, 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 fea03489b7fa..2686ace5ac2a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1584,6 +1584,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; } @@ -1640,7 +1658,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 +1669,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 +1699,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; } @@ -1705,8 +1724,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 +1739,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 +1851,7 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + text-align: start; } .chat-markdown thead th { From b22ca5083e37c26591cb532092eba4d78f4315c1 Mon Sep 17 00:00:00 2001 From: Asaf BenatiaDate: Wed, 19 Aug 2026 20:51:59 +0300 Subject: [PATCH 10/31] 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 e19a32c3116e..962ffedf697f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -393,7 +393,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); @@ -469,6 +469,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. */} +
From ff6b0c3656fcc39d74181e87355fb7695b0f303e Mon Sep 17 00:00:00 2001 From: Asaf Benatia
{alert.label} Date: Wed, 19 Aug 2026 21:19:11 +0300 Subject: [PATCH 11/31] 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 9499ee5a6915..43b7c43b2455 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", () => { @@ -34,3 +34,17 @@ describe("orderedListGutterStyle", () => { expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); }); }); + +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 962ffedf697f..b72182702b40 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, @@ -393,7 +394,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); @@ -468,19 +491,23 @@ function MarkdownTable({ children, dir, ...props }: React.ComponentProps<"table" className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} > - - + {/* 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} -
-+ + ++ {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 41b8e2c6da9c2d94632494c8dd13e7f2ab4a34a3 Mon Sep 17 00:00:00 2001 From: Asaf BenatiaDate: Wed, 19 Aug 2026 21:29:32 +0300 Subject: [PATCH 12/31] 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 43b7c43b2455..3227670ea273 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -39,6 +39,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 b72182702b40..5987b7f8539f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -394,8 +394,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; @@ -1603,14 +1605,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. */} -From 983c40b814aa667f5465430aff6e08ba5b906644 Mon Sep 17 00:00:00 2001 From: Asaf Benatia+
{children}
- {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} Date: Wed, 19 Aug 2026 21:41:21 +0300 Subject: [PATCH 13/31] 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 2686ace5ac2a..62c10b30659f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1592,7 +1592,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, From 0857f3ea880d144f70f39b48eb7d6d21536c8c20 Mon Sep 17 00:00:00 2001 From: Amit Date: Sat, 22 Aug 2026 22:48:30 +0300 Subject: [PATCH 14/31] fix(mobile): render Hebrew/Arabic chat markdown right-to-left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the web per-block bidi behavior to the iOS markdown renderer: - nativeMarkdownText.ts gains firstStrongDirection (same strong-RTL ranges as the web fix) and markdownBlockDirection, and threads a per-block writingDirection through the run conversion: the outermost prose block (paragraph, heading, list, blockquote) resolves its own direction from its first strong letter and nested blocks inherit it, while code blocks and tables stay pinned LTR. Inline code inside an RTL paragraph is wrapped in an LRI…PDI isolate so its symbols keep their left-to-right shape. - The T3MarkdownTextRun codegen spec gains a writingDirection prop (auto | ltr | rtl); the shadow node maps it to the fragment's baseWritingDirection, which TextKit's natural alignment follows. - List-marker paragraph ranges carry an rtl flag so both paragraph-style paths (measure + display) pin the base writing direction and flip the marker tab stop to a right-aligned one on the leading edge. - The rich block path mirrors the same rules in views: RTL lists use row-reverse with the marker column on the right, blockquote bars move to the right edge, and code block text pins writingDirection ltr. Co-Authored-By: Claude Fable 5 --- .../t3-markdown-text/ios/T3MarkdownText.mm | 7 +- .../ios/T3MarkdownTextShadowNode.h | 4 + .../ios/T3MarkdownTextShadowNode.mm | 18 +- .../src/NativeMarkdownBlock.ios.tsx | 48 ++++- .../src/NativeMarkdownSelectableText.ios.tsx | 5 + .../src/T3MarkdownTextRunNativeComponent.ts | 3 + .../src/nativeMarkdownText.test.ts | 188 ++++++++++++++++++ .../src/nativeMarkdownText.ts | 150 +++++++++++--- 8 files changed, 388 insertions(+), 35 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 6fa61aab17e9..c8479b408724 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -35,8 +35,13 @@ static void T3MarkdownTextApplyParagraphStyles( paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; paragraphStyle.headIndent = styleRange.headIndent; paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + if (styleRange.rtl) { + paragraphStyle.baseWritingDirection = NSWritingDirectionRightToLeft; + } + // Must match applyParagraphStyles in T3MarkdownTextShadowNode.mm (measure path). paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + [[NSTextTab alloc] initWithTextAlignment:styleRange.rtl ? NSTextAlignmentRight + : NSTextAlignmentLeft location:styleRange.headIndent options:@{}] ]; diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63b..30ea0110a333 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -20,6 +20,10 @@ struct T3MarkdownTextParagraphStyleRange { Float firstLineHeadIndent; Float headIndent; Float paragraphSpacing; + // RTL paragraphs (Hebrew/Arabic list items) need their base writing direction + // pinned and their marker tab stop right-aligned; TextKit flips the head + // indents to the leading (right) edge on its own. + bool rtl; }; struct T3MarkdownTextAttachmentRange { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb94..d2e4e698476a 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -34,8 +34,14 @@ static void applyParagraphStyles( paragraphStyle.firstLineHeadIndent = styleRange.firstLineHeadIndent; paragraphStyle.headIndent = styleRange.headIndent; paragraphStyle.paragraphSpacing = styleRange.paragraphSpacing; + if (styleRange.rtl) { + paragraphStyle.baseWritingDirection = NSWritingDirectionRightToLeft; + } + // The tab stop's alignment matches the paragraph's writing direction so the + // list-marker column sits on the leading edge (right, for RTL paragraphs). paragraphStyle.tabStops = @[ - [[NSTextTab alloc] initWithTextAlignment:NSTextAlignmentLeft + [[NSTextTab alloc] initWithTextAlignment:styleRange.rtl ? NSTextAlignmentRight + : NSTextAlignmentLeft location:styleRange.headIndent options:@{}] ]; @@ -171,6 +177,15 @@ static void applyAttachments( textAttributes.alignment = TextAlignment::Natural; } + // Natural alignment follows the paragraph's base writing direction, so an + // explicit "rtl" run right-aligns and reorders as Hebrew/Arabic prose while + // "ltr" pins code, and "auto" keeps TextKit's first-strong resolution. + if (props.writingDirection == T3MarkdownTextRunWritingDirection::Ltr) { + textAttributes.baseWritingDirection = WritingDirection::LeftToRight; + } else if (props.writingDirection == T3MarkdownTextRunWritingDirection::Rtl) { + textAttributes.baseWritingDirection = WritingDirection::RightToLeft; + } + textAttributes.backgroundColor = props.backgroundColor; fragment.string = props.text; @@ -185,6 +200,7 @@ static void applyAttachments( props.shadowOffset.width, props.shadowOffset.height, props.shadowRadius - ParagraphStyleEncodingOffset, + props.writingDirection == T3MarkdownTextRunWritingDirection::Rtl, }); } if (props.nativeId.rfind(FileAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 5fbe6d4dff44..782543a9bc55 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -4,7 +4,12 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; +import { + markdownBlockDirection, + nativeMarkdownDocumentRuns, + nativeMarkdownListItemBlocks, + type MarkdownWritingDirection, +} from "./nativeMarkdownText"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; import type { MarkdownCodeHighlighter, @@ -48,10 +53,11 @@ function SelectableNode(props: { readonly skills: ReadonlyArray ; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; + readonly direction?: MarkdownWritingDirection; }) { return ( @@ -170,6 +176,8 @@ function HighlightedCodeText(props: { fontFamily: "ui-monospace", fontSize: codeBlockFontSize(props.textStyle), lineHeight: codeBlockLineHeight(props.textStyle), + // Code stays LTR always — a Hebrew comment must not flip the snippet. + writingDirection: "ltr", }} > {props.content} @@ -205,6 +213,8 @@ function HighlightedCodeText(props: { fontFamily: "ui-monospace", fontSize: codeBlockFontSize(props.textStyle), lineHeight: codeBlockLineHeight(props.textStyle), + // Code stays LTR always — a Hebrew comment must not flip the snippet. + writingDirection: "ltr", }} > {keyedLines.map((line, lineIndex) => ( @@ -449,6 +459,7 @@ function NativeMixedParagraph(props: { readonly skills: ReadonlyArray ; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; + readonly direction?: MarkdownWritingDirection; }) { return ( @@ -468,6 +479,7 @@ function NativeMixedParagraph(props: { skills={props.skills} textStyle={props.textStyle} onLinkPress={props.onLinkPress} + direction={props.direction} /> ), )} @@ -482,10 +494,16 @@ function NativeList(props: { readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; readonly depth: number; + readonly direction?: MarkdownWritingDirection; }) { const ordered = props.node.ordered ?? false; const start = props.node.start ?? 1; const nested = props.depth > 0; + // The list takes one direction as a whole — inherited from the enclosing block, + // or resolved from the list's own first strong letter — so an RTL list paints + // every marker on the right of the text it labels. + const direction = props.direction ?? markdownBlockDirection(props.node); + const rtl = direction === "rtl"; return ( ); + } case "list": return ( @@ -544,6 +563,7 @@ function NativeList(props: { highlightCode={props.highlightCode} onLinkPress={props.onLinkPress} depth={props.depth + 1} + direction={direction} compact /> ))} @@ -563,6 +583,7 @@ export function NativeMarkdownBlock(props: { readonly onLinkPress?: (href: string) => void; readonly depth?: number; readonly compact?: boolean; + readonly direction?: MarkdownWritingDirection; }) { const depth = props.depth ?? 0; switch (props.node.type) { @@ -578,6 +599,7 @@ export function NativeMarkdownBlock(props: { highlightCode={props.highlightCode} onLinkPress={props.onLinkPress} depth={depth} + direction={props.direction} /> ))} @@ -618,14 +640,20 @@ export function NativeMarkdownBlock(props: { }} /> ); - case "blockquote": + case "blockquote": { + // The quote bar sits on the leading edge of its own text: right for a + // Hebrew/Arabic quote, left otherwise (per-block, like the web's dir="auto"). + const rtl = (props.direction ?? markdownBlockDirection(props.node)) === "rtl"; return ())} ); case "paragraph": @@ -662,6 +693,7 @@ export function NativeMarkdownBlock(props: { skills={props.skills} textStyle={props.textStyle} onLinkPress={props.onLinkPress} + direction={props.direction} /> ) : ( ; textDecorationColor?: ColorValue; textAlign?: WithDefault ; + writingDirection?: WithDefault ; shadowRadius?: WithDefault ; onPress?: BubblingEventHandler ; onLongPress?: BubblingEventHandler ; diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts new file mode 100644 index 000000000000..0479de42bcac --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { MarkdownNode } from "react-native-nitro-markdown/headless"; + +import { + firstStrongDirection, + markdownBlockDirection, + nativeMarkdownDocumentRuns, +} from "./nativeMarkdownText"; + +const HEBREW_MIXED = "שלום, זה טקסט בעברית עם מונח באנגלית כמו Claude Code בתוכו."; + +function text(content: string): MarkdownNode { + return { type: "text", content }; +} + +function paragraph(...children: MarkdownNode[]): MarkdownNode { + return { type: "paragraph", children }; +} + +function document(...children: MarkdownNode[]): MarkdownNode { + return { type: "document", children }; +} + +describe("firstStrongDirection", () => { + it("resolves Hebrew and Arabic text as RTL", () => { + expect(firstStrongDirection("שלום עולם")).toBe("rtl"); + expect(firstStrongDirection("مرحبا بالعالم")).toBe("rtl"); + }); + + it("resolves Latin text as LTR", () => { + expect(firstStrongDirection("Hello world")).toBe("ltr"); + }); + + it("lets the first letter decide when languages mix", () => { + expect(firstStrongDirection(HEBREW_MIXED)).toBe("rtl"); + expect(firstStrongDirection("Claude Code זה כלי")).toBe("ltr"); + }); + + it("skips neutral digits, punctuation and symbols", () => { + expect(firstStrongDirection('42 - "שלום"')).toBe("rtl"); + expect(firstStrongDirection("3. Hello")).toBe("ltr"); + }); + + it("defaults to LTR when no letter exists", () => { + expect(firstStrongDirection("")).toBe("ltr"); + expect(firstStrongDirection("123 !?")).toBe("ltr"); + }); +}); + +describe("markdownBlockDirection", () => { + it("reads the block's own text content", () => { + expect(markdownBlockDirection(paragraph(text("שלום")))).toBe("rtl"); + expect(markdownBlockDirection(paragraph(text("Hello")))).toBe("ltr"); + }); + + it("ignores code and tables when resolving the direction", () => { + expect( + markdownBlockDirection( + paragraph({ type: "code_inline", content: "npm install" }, text(" שלום")), + ), + ).toBe("rtl"); + expect( + markdownBlockDirection( + document({ type: "code_block", content: "שגיאה = 1" }, paragraph(text("Hello"))), + ), + ).toBe("ltr"); + }); + + it("ignores HTML tag names, but not the text they wrap", () => { + expect(markdownBlockDirection(paragraph({ type: "html_inline", content: "שלום" }))).toBe( + "rtl", + ); + }); +}); + +describe("nativeMarkdownDocumentRuns direction", () => { + it("marks a Hebrew paragraph RTL and an English one LTR in the same document", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text(HEBREW_MIXED)), paragraph(text("An English paragraph."))), + ); + const hebrew = runs.find((run) => run.text.includes("שלום")); + const english = runs.find((run) => run.text.includes("English")); + expect(hebrew?.writingDirection).toBe("rtl"); + expect(english?.writingDirection).toBe("ltr"); + }); + + it("gives a Hebrew list one RTL direction, markers included", () => { + const runs = nativeMarkdownDocumentRuns( + document({ + type: "list", + ordered: false, + children: [ + { type: "list_item", children: [paragraph(text("פריט ראשון"))] }, + { type: "list_item", children: [paragraph(text("Item in English"))] }, + ], + }), + ); + // The outermost list decides once; every run inherits (web: only the + // outermost block carries dir="auto", items inherit). + for (const run of runs) { + expect(run.writingDirection).toBe("rtl"); + } + expect(runs.some((run) => run.role === "list-marker")).toBe(true); + }); + + it("inherits the outer direction into nested lists", () => { + const runs = nativeMarkdownDocumentRuns( + document({ + type: "list", + ordered: false, + children: [ + { + type: "list_item", + children: [ + paragraph(text("רשימה בעברית")), + { + type: "list", + ordered: false, + children: [{ type: "list_item", children: [paragraph(text("English nested"))] }], + }, + ], + }, + ], + }), + ); + for (const run of runs) { + expect(run.writingDirection).toBe("rtl"); + } + }); + + it("marks a Hebrew heading RTL", () => { + const runs = nativeMarkdownDocumentRuns( + document({ type: "heading", level: 2, children: [text("כותרת בעברית")] }), + ); + expect(runs[0]?.writingDirection).toBe("rtl"); + }); + + it("pins code blocks LTR even when their content is Hebrew", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text("הסבר בעברית")), { + type: "code_block", + language: "js", + content: '// הערה בעברית\nconst x = "שלום";\n', + }), + ); + for (const run of runs.filter( + (item) => item.role === "code-block" || item.role === "code-language", + )) { + expect(run.writingDirection).toBe("ltr"); + } + }); + + it("keeps a blockquote one directional unit", () => { + const runs = nativeMarkdownDocumentRuns( + document({ + type: "blockquote", + children: [paragraph(text("ציטוט בעברית")), paragraph(text("English continuation"))], + }), + ); + for (const run of runs) { + expect(run.writingDirection).toBe("rtl"); + } + }); + + it("wraps inline code inside an RTL paragraph in an LTR isolate", () => { + const runs = nativeMarkdownDocumentRuns( + document( + paragraph(text("תריץ "), { type: "code_inline", content: "git status" }, text(" עכשיו")), + ), + ); + const code = runs.find((run) => run.code); + expect(code?.text).toBe("\u2066git status\u2069"); + expect(code?.writingDirection).toBe("rtl"); + }); + + it("leaves inline code inside an LTR paragraph untouched", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text("Run "), { type: "code_inline", content: "git status" })), + ); + const code = runs.find((run) => run.code); + expect(code?.text).toBe("git status"); + }); + + it("honors an explicitly inherited direction", () => { + const runs = nativeMarkdownDocumentRuns(document(paragraph(text("English text"))), [], "rtl"); + expect(runs[0]?.writingDirection).toBe("rtl"); + }); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 8db904b5a6ca..80a76110f98c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -3,6 +3,8 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; +export type MarkdownWritingDirection = "ltr" | "rtl"; + export interface NativeMarkdownTextRun { readonly text: string; readonly bold?: boolean; @@ -30,6 +32,7 @@ export interface NativeMarkdownTextRun { readonly firstLineHeadIndent?: number; readonly headIndent?: number; readonly paragraphSpacing?: number; + readonly writingDirection?: MarkdownWritingDirection; } export type NativeMarkdownDocumentChunk = @@ -59,6 +62,7 @@ interface RunContext { readonly firstLineHeadIndent?: number; readonly headIndent?: number; readonly paragraphSpacing?: number; + readonly writingDirection?: MarkdownWritingDirection; } const EMPTY_CONTEXT: RunContext = { @@ -70,6 +74,44 @@ const EMPTY_CONTEXT: RunContext = { const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi; +// 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 = /[-ࣿיִ-﷿ﹰ-\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 the web app's `dir="auto"` would resolve. +export function firstStrongDirection(text: string): MarkdownWritingDirection { + const letter = FIRST_LETTER.exec(text)?.[0]; + return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; +} + +// Code and tables opt out of direction detection and stay LTR: their shape is not +// prose, so their letters must not decide the direction of the block around them — +// the same nodes the web app pins with an explicit `dir="ltr"` (which `dir="auto"` +// then skips when resolving an ancestor). +const DIRECTION_NEUTRAL_NODE_TYPES = new Set(["code_block", "code_inline", "table"]); + +function directionSourceText(node: MarkdownNode): string { + if (DIRECTION_NEUTRAL_NODE_TYPES.has(node.type)) { + return ""; + } + if (node.type === "html_inline" || node.type === "html_block") { + // Tag names are letters too — only the text an HTML node renders may vote. + return inlineHtmlText(nodeTextContent(node)); + } + if (node.content !== undefined) { + return node.content; + } + return (node.children ?? []).map(directionSourceText).join(""); +} + +// The base direction of a markdown block, resolved from the block's own first +// strong letter (mirroring the web renderer's per-block `dir="auto"`). +export function markdownBlockDirection(node: MarkdownNode): MarkdownWritingDirection { + return firstStrongDirection(directionSourceText(node)); +} + function decodeCodePoint(codePoint: number, entity: string): string { if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { return entity; @@ -147,7 +189,8 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun) left.spacing === right.spacing && left.firstLineHeadIndent === right.firstLineHeadIndent && left.headIndent === right.headIndent && - left.paragraphSpacing === right.paragraphSpacing + left.paragraphSpacing === right.paragraphSpacing && + left.writingDirection === right.writingDirection ); } @@ -180,6 +223,7 @@ function appendRun( ...(context.paragraphSpacing !== undefined ? { paragraphSpacing: context.paragraphSpacing } : {}), + ...(context.writingDirection ? { writingDirection: context.writingDirection } : {}), }; const previous = runs.at(-1); if (previous && sameRunStyle(previous, run)) { @@ -283,8 +327,17 @@ function appendNode( return appendRun(runs, textNodeContent(nodeTextContent(node)), context); case "html_inline": return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); - case "code_inline": - return appendRun(runs, nodeTextContent(node), { ...context, code: true }); + case "code_inline": { + // Inline code keeps its left-to-right shape even inside an RTL paragraph + // (the web pins `code` to LTR with CSS). Attributed strings have no + // per-span direction, so wrap the span in an LTR isolate (LRI … PDI). + const content = nodeTextContent(node); + return appendRun( + runs, + context.writingDirection === "rtl" ? `\u2066${content}\u2069` : content, + { ...context, code: true }, + ); + } case "soft_break": return appendRun(runs, " ", context); case "line_break": @@ -404,6 +457,7 @@ function appendListItem( marker: string, depth: number, markerColumnWidth: number, + writingDirection: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { const firstLineHeadIndent = Math.max(0, depth - 1) * 20; appendRun(runs, `${marker}\t`, { @@ -413,6 +467,7 @@ function appendListItem( firstLineHeadIndent, headIndent: firstLineHeadIndent + markerColumnWidth, paragraphSpacing: 2, + writingDirection, }); const children = node.children ?? []; @@ -423,6 +478,7 @@ function appendListItem( ...EMPTY_CONTEXT, role: "body", depth, + writingDirection, }); wroteInlineContent = true; continue; @@ -434,9 +490,10 @@ function appendListItem( role: "list-break", depth, spacing: 1, + writingDirection, }); } - appendList(runs, child, depth + 1); + appendList(runs, child, depth + 1, writingDirection); wroteInlineContent = false; continue; } @@ -445,11 +502,12 @@ function appendListItem( ...EMPTY_CONTEXT, role: "body", depth, + writingDirection, }); wroteInlineContent = true; continue; } - appendDocumentBlock(runs, child, depth); + appendDocumentBlock(runs, child, depth, writingDirection); wroteInlineContent = true; } @@ -459,6 +517,7 @@ function appendListItem( role: "list-break", depth, spacing: depth === 1 ? 4 : 2, + writingDirection, }); } return runs; @@ -468,6 +527,9 @@ function appendList( runs: NativeMarkdownTextRun[], node: MarkdownNode, depth: number, + // A list takes one direction as a whole — the outermost block of a run decides + // and items inherit, so the markers stay on the same side as the text they label. + writingDirection: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { const ordered = node.ordered ?? false; const start = node.start ?? 1; @@ -499,7 +561,7 @@ function appendList( : marker; const markerColumnWidth = child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; - appendListItem(runs, child, alignedMarker, depth, markerColumnWidth); + appendListItem(runs, child, alignedMarker, depth, markerColumnWidth, writingDirection); } return runs; } @@ -508,27 +570,30 @@ function appendQuoteBlock( runs: NativeMarkdownTextRun[], node: MarkdownNode, depth: number, + writingDirection: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { for (const [index, child] of (node.children ?? []).entries()) { if (index > 0) { - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth, writingDirection }); } appendRun(runs, "│\u00a0", { ...EMPTY_CONTEXT, role: "quote-marker", depth, + writingDirection, }); if (child.type === "paragraph") { appendInlineChildren(runs, child, { ...EMPTY_CONTEXT, role: "body", depth, + writingDirection, }); } else { - appendDocumentBlock(runs, child, depth); + appendDocumentBlock(runs, child, depth, writingDirection); } } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth, writingDirection }); return runs; } @@ -544,6 +609,7 @@ function appendTableRow( ...EMPTY_CONTEXT, role: "divider", depth, + writingDirection: "ltr", }); } appendInlineChildren(runs, cell, { @@ -551,9 +617,10 @@ function appendTableRow( role: "body", bold: cell.isHeader ?? false, depth, + writingDirection: "ltr", }); } - appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth, writingDirection: "ltr" }); return runs; } @@ -579,6 +646,10 @@ function appendDocumentBlock( runs: NativeMarkdownTextRun[], node: MarkdownNode, depth = 0, + // Only the outermost block of a run resolves its own direction; nested blocks + // inherit it, so a list or quote reads as one directional unit (the web marks + // only the outermost block with `dir="auto"` for the same reason). + direction?: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { switch (node.type) { case "document": { @@ -591,7 +662,7 @@ function appendDocumentBlock( child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12, ); } - appendDocumentBlock(runs, child, depth); + appendDocumentBlock(runs, child, depth, direction); } return runs; } @@ -601,26 +672,35 @@ function appendDocumentBlock( role: "heading", headingLevel: node.level ?? 1, depth, + writingDirection: direction ?? markdownBlockDirection(node), }; appendInlineChildren(runs, node, context); return appendBlockTerminator(runs, context); } case "paragraph": { - const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth }; + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "body", + depth, + writingDirection: direction ?? markdownBlockDirection(node), + }; appendInlineChildren(runs, node, context); return appendBlockTerminator(runs, context); } case "list": - return appendList(runs, node, depth + 1); + return appendList(runs, node, depth + 1, direction ?? markdownBlockDirection(node)); case "blockquote": - return appendQuoteBlock(runs, node, depth); + return appendQuoteBlock(runs, node, depth, direction ?? markdownBlockDirection(node)); case "code_block": { + // Code stays LTR always: identifiers and paths read the same in every + // locale, and a Hebrew comment must not flip the snippet. if (node.language) { appendRun(runs, `${node.language.toUpperCase()}\n`, { ...EMPTY_CONTEXT, role: "code-language", code: true, depth, + writingDirection: "ltr", }); } const content = nodeTextContent(node); @@ -629,6 +709,7 @@ function appendDocumentBlock( role: "code-block", code: true, depth, + writingDirection: "ltr", }); if (!content.endsWith("\n")) { appendBlockTerminator(runs, { @@ -636,6 +717,7 @@ function appendDocumentBlock( role: "code-block", code: true, depth, + writingDirection: "ltr", }); } return runs; @@ -649,19 +731,36 @@ function appendDocumentBlock( return runs; case "table": return appendTable(runs, node, depth); - case "html_block": - appendRun(runs, inlineHtmlText(nodeTextContent(node)), { + case "html_block": { + const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth, - }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - case "math_block": - appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); - default: - appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth }); - return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth }); + writingDirection: direction ?? markdownBlockDirection(node), + }; + appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + return appendBlockTerminator(runs, context); + } + case "math_block": { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "body", + depth, + writingDirection: direction ?? markdownBlockDirection(node), + }; + appendRun(runs, nodeTextContent(node), context); + return appendBlockTerminator(runs, context); + } + default: { + const context: RunContext = { + ...EMPTY_CONTEXT, + role: "body", + depth, + writingDirection: direction ?? markdownBlockDirection(node), + }; + appendInlineChildren(runs, node, context); + return appendBlockTerminator(runs, context); + } } } @@ -750,8 +849,9 @@ export function nativeMarkdownChunkSpacing( export function nativeMarkdownDocumentRuns( node: MarkdownNode, skills: ReadonlyArray = [], + direction?: MarkdownWritingDirection, ): ReadonlyArray { - const runs = appendDocumentBlock([], node); + const runs = appendDocumentBlock([], node, 0, direction); while (runs.length > 0) { const lastIndex = runs.length - 1; const last = runs[lastIndex]; From 574cbc8fa3623ce94ae0400cc63408de31a7cc02 Mon Sep 17 00:00:00 2001 From: Amit Date: Sun, 23 Aug 2026 02:07:04 +0300 Subject: [PATCH 15/31] chore: lockfile refresh after mobile module install Co-Authored-By: Claude Fable 5 --- pnpm-lock.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f66f69b87f0..3e6c361df373 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5184,10 +5184,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} From fbffd37e95fdd30a3eb8275721470ad636a76109 Mon Sep 17 00:00:00 2001 From: Amit Date: Sun, 23 Aug 2026 10:40:49 +0300 Subject: [PATCH 16/31] fix(web): read Hebrew blocks that open with Latin tech tokens right-to-left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Hebrew sentence that begins with a URL, a path or a file name — "server.py זה הקובץ הראשי" — resolved LTR, because both dir="auto" and the plaintext CSS take the browser's first-strong scan at face value. Inspired by stripLeadingLTR from shraga100/claude-desktop-rtl-patch: - resolvedTextDirection: first-strong, but when the text leads with a Latin tech token and still contains strong-RTL script, re-run first-strong with the tech tokens (URLs, inline code, paths, file names) blanked out. A mostly-English block with one Hebrew word stays LTR — no "any RTL anywhere" fallback. - remarkTextDirection judges each outermost block from its mdast text with the LTR-pinned nodes (inline code, fences, tables) excluded structurally, and pins dir="rtl" only where the heuristic overrules plain first-strong; everything else keeps dir="auto". - index.css lifts unicode-bidi: plaintext for pinned blocks (and the plaintext leaves inside a pinned list/quote) so the explicit dir wins. - hastTextContent skips , so the table wrapper direction judges prose only, mirroring the mdast pass. Co-Authored-By: Claude Fable 5--- apps/web/src/components/ChatMarkdown.test.tsx | 72 ++++++++++++++++++- apps/web/src/components/ChatMarkdown.tsx | 65 +++++++++++++++-- apps/web/src/index.css | 11 +++ 3 files changed, 143 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 721c33e8b7ec..f8e55f72d0bb 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,7 +1,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import ChatMarkdown, { firstStrongDirection, orderedListGutterStyle } from "./ChatMarkdown"; +import ChatMarkdown, { + firstStrongDirection, + orderedListGutterStyle, + resolvedTextDirection, +} from "./ChatMarkdown"; describe("orderedListGutterStyle", () => { it("leaves the default gutter alone for single-digit lists", () => { @@ -99,6 +103,72 @@ describe("chat markdown text direction", () => { const html = render("| Name | value |\n| --- | --- |\n| a | 1 |"); expect(html).not.toContain('dir="rtl"'); }); + + it('keeps a Hebrew block opening with an inline-code span on dir="auto"', () => { + // The code span carries its own dir="ltr", so both the plugin's detection + // text and the browser's dir="auto" scan skip it — no pin needed. + const html = render("`server.py` זה הקובץ הראשי"); + expect(html).toContain(' '); + expect(html).not.toContain('
'); + }); + + it("pins a Hebrew block that opens with a URL right-to-left", () => { + const html = render("https://claude.ai זה האתר של קלוד"); + expect(html).toContain('
'); + }); + + it("pins a Hebrew block that opens with a path right-to-left", () => { + const html = render("src/main.ts זה הקובץ שצריך לערוך"); + expect(html).toContain('
'); + }); + + it("pins a Hebrew list that opens with a tech token right-to-left, markers included", () => { + const html = render("- server.py זה הקובץ\n- עוד פריט"); + expect(html).toContain('
'); + }); + + it("keeps an English block with one Hebrew word on the browser's own resolution", () => { + const html = render("The word שלום means hello"); + expect(html).toContain('
'); + expect(html).not.toContain('dir="rtl"'); + }); + + it("keeps a pure English block on the browser's own resolution", () => { + const html = render("English only, no tech tokens."); + expect(html).toContain('
'); + expect(html).not.toContain('dir="rtl"'); + }); + + it('leaves a Hebrew-first block on dir="auto", unchanged', () => { + const html = render("שלום, תריץ `git status` עכשיו"); + expect(html).toContain('
'); + expect(html).not.toContain('
'); + }); + + it("gives a table opening with a tech-token cell its direction from its prose", () => { + const html = render("| `id.ts` | שם |\n| --- | --- |\n| `a.py` | קובץ |"); + expect(html).toContain('dir="rtl"'); + }); +}); + +describe("resolvedTextDirection", () => { + it("discounts leading tech tokens when the text is RTL prose", () => { + expect(resolvedTextDirection("https://claude.ai זה האתר של קלוד")).toBe("rtl"); + expect(resolvedTextDirection("server.py זה הקובץ הראשי")).toBe("rtl"); + expect(resolvedTextDirection("src/main.ts זה הקובץ")).toBe("rtl"); + expect(resolvedTextDirection("`git status` תריץ קודם")).toBe("rtl"); + }); + + it("keeps English text left-to-right, one Hebrew word or none", () => { + expect(resolvedTextDirection("The word שלום means hello")).toBe("ltr"); + expect(resolvedTextDirection("Hello world")).toBe("ltr"); + // Only tech tokens are discounted; leading English *words* still decide. + expect(resolvedTextDirection("Claude Code זה כלי")).toBe("ltr"); + }); + + it("keeps Hebrew-first text right-to-left, unchanged", () => { + expect(resolvedTextDirection("שלום, זה טקסט עם Claude Code בתוכו")).toBe("rtl"); + }); }); describe("firstStrongDirection", () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6c514e645b16..e9b001d0b563 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -291,6 +291,7 @@ function extractPreCodeMeta(node: unknown): string | undefined { type MarkdownAstNode = { type?: string; meta?: unknown; + value?: string; data?: { hProperties?: Record
; }; @@ -363,7 +364,22 @@ const AUTO_DIRECTION_NODE_TYPES = new Set([ ]); const LTR_DIRECTION_NODE_TYPES = new Set(["code", "inlineCode", "table"]); -function setDirection(node: MarkdownAstNode, dir: "auto" | "ltr") { +/** + * The text a block's direction is judged from: its own prose, with the + * LTR-pinned nodes (inline code, fences, tables) excluded structurally — + * `dir="auto"` skips them too, since they carry their own `dir`. + */ +function directionDetectionText(node: MarkdownAstNode): string { + if (LTR_DIRECTION_NODE_TYPES.has(node.type ?? "")) { + return ""; + } + if (typeof node.value === "string") { + return node.value; + } + return (node.children ?? []).map(directionDetectionText).join(""); +} + +function setDirection(node: MarkdownAstNode, dir: "auto" | "ltr" | "rtl") { node.data = { ...node.data, hProperties: { @@ -402,7 +418,19 @@ function remarkTextDirection() { const isAutoBlock = !insideAutoBlock && !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type); if (isAutoBlock) { - setDirection(node, "auto"); + // `dir="auto"` (and the `plaintext` CSS) is the browser's own first-strong + // scan, which cannot discount a leading Latin tech token — "server.py זה + // הקובץ" resolves LTR. When the heuristic disagrees with plain first-strong, + // pin the block with an explicit `dir="rtl"` (index.css lifts `plaintext` + // for it); everywhere else the browser keeps resolving the block itself. + const detectionText = directionDetectionText(node); + setDirection( + node, + firstStrongDirection(detectionText) === "ltr" && + resolvedTextDirection(detectionText) === "rtl" + ? "rtl" + : "auto", + ); } node.children?.forEach((child) => visit(child, insideAutoBlock || isAutoBlock)); }; @@ -477,10 +505,39 @@ export function firstStrongDirection(text: string): TextDirection { return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; } +// The tech tokens a Hebrew sentence often *opens* with — a URL, an inline-code +// span, a path, a file name ("server.py זה הקובץ הראשי"). Their Latin letters +// are identifiers, not prose, so they must not get the first-strong vote. +// Mirrors the mobile app's pattern (each app keeps its own copy — no cross-app +// imports) and stripLeadingLTR from the claude-desktop-rtl-patch. +const LTR_TECH_TOKEN = /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b/gu; + +function stripLtrTechTokens(text: string): string { + // A token carrying its own strong-RTL letters (an RTL slash pair like כן/לא) + // is prose, not a tech identifier — it keeps its vote. + return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); +} + +// First-strong, with one correction: text that *leads* with a Latin tech token +// but is otherwise RTL prose re-runs first-strong with those tokens stripped. +// A mostly-English text with one Hebrew word stays LTR — the stripped re-run +// still leads with its English words. +export function resolvedTextDirection(text: string): TextDirection { + if (firstStrongDirection(text) === "rtl") { + return "rtl"; + } + if (!STRONG_RTL_CHAR.test(text)) { + return "ltr"; + } + return firstStrongDirection(stripLtrTechTokens(text)); +} + function hastTextContent(node: unknown): string { if (!node || typeof node !== "object") return ""; - const n = node as { type?: string; value?: string; children?: unknown[] }; + const n = node as { type?: string; tagName?: string; value?: string; children?: unknown[] }; if (n.type === "text") return n.value ?? ""; + // Code is direction-neutral here too, mirroring the mdast-side exclusion. + if (n.tagName === "code") return ""; return (n.children ?? []).map(hastTextContent).join(""); } @@ -1858,7 +1915,7 @@ function ChatMarkdown({ ); }, table({ node, dir: _dir, ...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 22a2d79313a0..49d06b0f9e30 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1988,6 +1988,17 @@ code { unicode-bidi: plaintext; } +/* A block ChatMarkdown.tsx pinned `dir="rtl"`: RTL prose that *opens* with a + Latin tech token (a URL, a path, `server.py`), which the browser's own + first-strong scan would misread as LTR. `plaintext` ignores the `dir` + attribute, so the pinned block — and the plaintext leaves inside a pinned + list or quote, which would otherwise re-resolve themselves line by line — + falls back to `isolate` and inherits the pinned direction. */ +.chat-markdown [dir="rtl"], +.chat-markdown [dir="rtl"] :is(p, li, h1, h2, h3, h4, h5, h6, td, th, dt, dd) { + unicode-bidi: isolate; +} + .chat-markdown pre, .chat-markdown code, .chat-markdown .chat-markdown-codeblock { From 1c588e129f627c71019dab4011f9291e37971db6 Mon Sep 17 00:00:00 2001 From: AmitDate: Sun, 23 Aug 2026 10:40:50 +0300 Subject: [PATCH 17/31] fix(web): flip the composer direction live with the draft's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dir="auto" on the Lexical ContentEditable: the browser re-reads the draft's first strong character on every input, so a Hebrew draft flips the composer RTL (text-align follows start) as it is typed and an empty composer falls back to LTR. Plain first-strong on purpose — while typing, follow what the user actually typed, no tech-token stripping. No composer CSS forces a text-align that could fight it. Co-Authored-By: Claude Fable 5 --- apps/web/src/components/ComposerPromptEditor.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 15d31c7323b0..f049c6f888ca 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1752,6 +1752,14 @@ function ComposerPromptEditorInner({ Date: Sun, 23 Aug 2026 10:41:05 +0300 Subject: [PATCH 18/31] fix(mobile): read Hebrew blocks that open with Latin tech tokens right-to-left Same rule as the web renderer (each app keeps its own copy), inspired by stripLeadingLTR from shraga100/claude-desktop-rtl-patch: - resolvedTextDirection: first-strong, but when the text leads with a Latin tech token (URL, inline code, path, file name) and still contains strong-RTL script, re-run first-strong with those tokens blanked out. A mostly-English block with one Hebrew word stays LTR. - markdownBlockDirection now resolves through it; code spans were already excluded structurally by directionSourceText, the strip fallback covers tokens living in plain text. - Also updates the stale expectations in src/lib/nativeMarkdownText.test.ts that 0857f3ea8 left behind when runs gained writingDirection (they were failing on the branch before this change). Co-Authored-By: Claude Fable 5 --- .../src/nativeMarkdownText.test.ts | 43 +++++++++++++++++++ .../src/nativeMarkdownText.ts | 34 ++++++++++++++- .../mobile/src/lib/nativeMarkdownText.test.ts | 21 ++++++--- 3 files changed, 89 insertions(+), 9 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts index 0479de42bcac..5315bb06ec75 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts @@ -5,6 +5,7 @@ import { firstStrongDirection, markdownBlockDirection, nativeMarkdownDocumentRuns, + resolvedTextDirection, } from "./nativeMarkdownText"; const HEBREW_MIXED = "שלום, זה טקסט בעברית עם מונח באנגלית כמו Claude Code בתוכו."; @@ -47,7 +48,49 @@ describe("firstStrongDirection", () => { }); }); +describe("resolvedTextDirection", () => { + it("reads Hebrew that opens with a URL right-to-left", () => { + expect(resolvedTextDirection("https://claude.ai זה האתר של קלוד")).toBe("rtl"); + }); + + it("reads Hebrew that opens with a file name right-to-left", () => { + expect(resolvedTextDirection("server.py זה הקובץ הראשי")).toBe("rtl"); + }); + + it("reads Hebrew that opens with a path right-to-left", () => { + expect(resolvedTextDirection("src/main.ts זה הקובץ שצריך לערוך")).toBe("rtl"); + }); + + it("reads Hebrew that opens with an inline-code span right-to-left", () => { + expect(resolvedTextDirection("`git status` תריץ קודם")).toBe("rtl"); + }); + + it("keeps English with one Hebrew word left-to-right", () => { + expect(resolvedTextDirection("The word שלום means hello")).toBe("ltr"); + }); + + it("keeps pure English left-to-right", () => { + expect(resolvedTextDirection("Hello world")).toBe("ltr"); + }); + + it("keeps Hebrew-first text right-to-left, unchanged", () => { + expect(resolvedTextDirection(HEBREW_MIXED)).toBe("rtl"); + }); + + it("keeps plain English words before Hebrew left-to-right (no tech token)", () => { + // Only tech tokens are discounted; leading English *words* still decide. + expect(resolvedTextDirection("Claude Code זה כלי")).toBe("ltr"); + }); +}); + describe("markdownBlockDirection", () => { + it("discounts a leading file name in plain paragraph text", () => { + expect(markdownBlockDirection(paragraph(text("server.py זה הקובץ הראשי")))).toBe("rtl"); + }); + + it("discounts a leading URL in plain paragraph text", () => { + expect(markdownBlockDirection(paragraph(text("https://claude.ai האתר של קלוד")))).toBe("rtl"); + }); it("reads the block's own text content", () => { expect(markdownBlockDirection(paragraph(text("שלום")))).toBe("rtl"); expect(markdownBlockDirection(paragraph(text("Hello")))).toBe("ltr"); diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 80a76110f98c..f86c577aa508 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -86,6 +86,33 @@ export function firstStrongDirection(text: string): MarkdownWritingDirection { return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; } +// The tech tokens a Hebrew sentence often *opens* with — a URL, an inline-code +// span, a path, a file name ("server.py זה הקובץ הראשי"). Their Latin letters +// are identifiers, not prose, so they must not get the first-strong vote. +// Mirrors the web app's pattern (each app keeps its own copy — no cross-app +// imports) and stripLeadingLTR from the claude-desktop-rtl-patch. +const LTR_TECH_TOKEN = /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b/gu; + +function stripLtrTechTokens(text: string): string { + // A token carrying its own strong-RTL letters (an RTL slash pair like כן/לא) + // is prose, not a tech identifier — it keeps its vote. + return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); +} + +// First-strong, with one correction: text that *leads* with a Latin tech token +// but is otherwise RTL prose re-runs first-strong with those tokens stripped. +// A mostly-English text with one Hebrew word stays LTR — the stripped re-run +// still leads with its English words. +export function resolvedTextDirection(text: string): MarkdownWritingDirection { + if (firstStrongDirection(text) === "rtl") { + return "rtl"; + } + if (!STRONG_RTL_CHAR.test(text)) { + return "ltr"; + } + return firstStrongDirection(stripLtrTechTokens(text)); +} + // Code and tables opt out of direction detection and stay LTR: their shape is not // prose, so their letters must not decide the direction of the block around them — // the same nodes the web app pins with an explicit `dir="ltr"` (which `dir="auto"` @@ -107,9 +134,12 @@ function directionSourceText(node: MarkdownNode): string { } // The base direction of a markdown block, resolved from the block's own first -// strong letter (mirroring the web renderer's per-block `dir="auto"`). +// strong letter (mirroring the web renderer's per-block `dir="auto"`) — with +// leading Latin tech tokens discounted. Code spans are already excluded +// structurally by directionSourceText; URLs, paths and file names living in +// plain text are handled by the strip fallback. export function markdownBlockDirection(node: MarkdownNode): MarkdownWritingDirection { - return firstStrongDirection(directionSourceText(node)); + return resolvedTextDirection(directionSourceText(node)); } function decodeCodePoint(codePoint: number, entity: string): string { diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 867d9e983017..ac91b3665f4c 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -178,14 +178,15 @@ describe("nativeMarkdownDocumentRuns", () => { }; expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toEqual([ - { text: "Use ", role: "body" }, + { text: "Use ", role: "body", writingDirection: "ltr" }, { text: "$ui", role: "body", skillName: "ui", skillLabel: "UI", + writingDirection: "ltr", }, - { text: " for this.", role: "body" }, + { text: " for this.", role: "body", writingDirection: "ltr" }, ]); }); @@ -205,6 +206,7 @@ describe("nativeMarkdownDocumentRuns", () => { role: "body", skillName: "ui", skillLabel: "UI", + writingDirection: "ltr", }); }); @@ -220,7 +222,7 @@ describe("nativeMarkdownDocumentRuns", () => { }; expect(nativeMarkdownDocumentRuns(node, [])).toEqual([ - { text: "Use $unknown for this.", role: "body" }, + { text: "Use $unknown for this.", role: "body", writingDirection: "ltr" }, ]); }); @@ -276,11 +278,13 @@ describe("nativeMarkdownDocumentRuns", () => { text: "Header One\n", role: "heading", headingLevel: 1, + writingDirection: "ltr", }); expect(runs).toContainEqual({ text: "bold text", bold: true, role: "body", + writingDirection: "ltr", }); expect(runs).toContainEqual({ text: "•\t", @@ -289,6 +293,7 @@ describe("nativeMarkdownDocumentRuns", () => { firstLineHeadIndent: 0, headIndent: 24, paragraphSpacing: 2, + writingDirection: "ltr", }); }); @@ -355,11 +360,12 @@ describe("nativeMarkdownDocumentRuns", () => { firstLineHeadIndent: 0, headIndent: 24, paragraphSpacing: 2, + writingDirection: "ltr", }, - { text: "Finding:", bold: true, role: "body", depth: 1 }, - { text: " details with ", role: "body", depth: 1 }, - { text: "inline code", code: true, role: "body", depth: 1 }, - { text: ".", role: "body", depth: 1 }, + { text: "Finding:", bold: true, role: "body", depth: 1, writingDirection: "ltr" }, + { text: " details with ", role: "body", depth: 1, writingDirection: "ltr" }, + { text: "inline code", code: true, role: "body", depth: 1, writingDirection: "ltr" }, + { text: ".", role: "body", depth: 1, writingDirection: "ltr" }, ]); }); @@ -390,6 +396,7 @@ describe("nativeMarkdownDocumentRuns", () => { text: "const answer = 42;", code: true, role: "code-block", + writingDirection: "ltr", }); }); From 374a0922316cb819a5d1fb7bdcea62a8a4304c3a Mon Sep 17 00:00:00 2001 From: Amit Date: Sun, 23 Aug 2026 10:41:06 +0300 Subject: [PATCH 19/31] fix(mobile): flip the composer direction live with the draft's language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The draft's first strong letter decides (plain first-strong, no tech-token stripping — while typing, follow what the user actually typed; empty resets to LTR), computed in JS and threaded to native the same way writingDirection was threaded for t3-markdown-text: - T3ComposerEditor.ios.tsx passes a writingDirection prop derived from the controlled value on every change. - The Swift view rides the direction on the base paragraph style (baseWritingDirection + natural alignment, which typingAttributes and restoreBaseTypingAttributes already propagate), restyles the existing textStorage in place — never a rebuild that could race the revision guard mid-typing — and mirrors the placeholder alignment. - The plain-TextInput fallback applies textAlign + writingDirection from the same detection. Android's native editor is untouched: EditText already resolves textDirection firstStrong on its own. Co-Authored-By: Claude Fable 5 --- .../ios/T3ComposerEditorModule.swift | 3 ++ .../ios/T3ComposerEditorView.swift | 40 +++++++++++++++++-- .../src/native/T3ComposerEditor.ios.tsx | 6 +++ apps/mobile/src/native/T3ComposerEditor.tsx | 7 ++++ 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift index a56619b7d483..4e8df6e8daa7 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorModule.swift @@ -26,6 +26,9 @@ public class T3ComposerEditorModule: Module { Prop("contentInsetVertical") { (view: T3ComposerEditorView, contentInsetVertical: Double) in view.setContentInsetVertical(CGFloat(contentInsetVertical)) } + Prop("writingDirection") { (view: T3ComposerEditorView, writingDirection: String) in + view.setWritingDirection(writingDirection) + } Prop("editable") { (view: T3ComposerEditorView, editable: Bool) in view.setEditable(editable) } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 2a8fb8c4ea26..e557ea9a675c 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -304,6 +304,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var fontSize: CGFloat = 14 private var lineHeight: CGFloat = 20 private var contentInsetVertical: CGFloat = 0 + private var isRightToLeft = false private var shouldAutoFocus = false private var didAutoFocus = false private var isApplyingControlledValue = false @@ -447,6 +448,31 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro setNeedsLayout() } + // Live composer direction, decided in JS from the draft's first strong + // letter. UIKit does not re-resolve a text view's base direction from its + // content, and restoreBaseTypingAttributes would clobber any keyboard-driven + // direction anyway — so the direction rides the base paragraph style, which + // TextKit's natural alignment follows, and the existing text is restyled in + // place (never rebuilt: a rebuild from the controlled value could race a + // keystroke the revision guard has not acknowledged yet). + func setWritingDirection(_ writingDirection: String) { + let isRTL = writingDirection == "rtl" + guard isRTL != isRightToLeft else { + return + } + isRightToLeft = isRTL + placeholderLabel.textAlignment = isRTL ? .right : .left + let storageRange = NSRange(location: 0, length: textView.textStorage.length) + if storageRange.length > 0 { + textView.textStorage.addAttribute( + .paragraphStyle, + value: baseParagraphStyle(), + range: storageRange + ) + } + restoreBaseTypingAttributes() + } + func setEditable(_ editable: Bool) { textView.isEditable = editable } @@ -728,16 +754,22 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro return nil } - private func baseAttributes() -> [NSAttributedString.Key: Any] { - let font = UIFont(name: fontFamily, size: fontSize) - ?? UIFont.systemFont(ofSize: fontSize) + private func baseParagraphStyle() -> NSParagraphStyle { let paragraph = NSMutableParagraphStyle() paragraph.minimumLineHeight = lineHeight paragraph.maximumLineHeight = lineHeight + paragraph.baseWritingDirection = isRightToLeft ? .rightToLeft : .leftToRight + paragraph.alignment = .natural + return paragraph + } + + private func baseAttributes() -> [NSAttributedString.Key: Any] { + let font = UIFont(name: fontFamily, size: fontSize) + ?? UIFont.systemFont(ofSize: fontSize) return [ .font: font, .foregroundColor: UIColor(composerHex: theme.text) ?? .label, - .paragraphStyle: paragraph, + .paragraphStyle: baseParagraphStyle(), ] } diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 32094109b1f3..0f1eedb32602 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -14,6 +14,7 @@ import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; +import { firstStrongDirection } from "@t3tools/mobile-markdown-text/markdown"; import { useThemeColor } from "../lib/useThemeColor"; import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; @@ -61,6 +62,7 @@ interface NativeComposerEditorProps extends ViewProps { readonly fontSize: number; readonly lineHeight: number; readonly contentInsetVertical: number; + readonly writingDirection: "ltr" | "rtl"; readonly editable: boolean; readonly scrollEnabled: boolean; readonly autoFocus: boolean; @@ -250,6 +252,10 @@ export function ComposerEditor({ : bodyText.lineHeight } contentInsetVertical={contentInsetVertical} + // Live composer direction: the draft's first strong letter decides + // (plain first-strong, no tech-token stripping — while typing, follow + // what the user actually typed; empty resets to LTR). + writingDirection={firstStrongDirection(props.value)} editable={props.editable ?? true} scrollEnabled={props.scrollEnabled ?? true} autoFocus={props.autoFocus ?? false} diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index e082d3892ad9..a647aedcb98c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -2,6 +2,7 @@ import { TextInputWrapper } from "expo-paste-input"; import { useImperativeHandle, useRef } from "react"; import { TextInput, type TextInput as RNTextInput } from "react-native"; +import { firstStrongDirection } from "@t3tools/mobile-markdown-text/markdown"; import { useThemeColor } from "../lib/useThemeColor"; import { useFontFamily } from "../lib/useFontFamily"; import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole"; @@ -25,6 +26,10 @@ export function ComposerEditor({ const placeholderColor = useThemeColor("--color-placeholder"); const fontFamily = useFontFamily("regular"); const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); + // Live composer direction: the draft's first strong letter decides (plain + // first-strong — while typing, follow what the user actually typed; empty + // resets to LTR). `writingDirection` is iOS-only; `textAlign` covers both. + const writingDirection = firstStrongDirection(props.value); useImperativeHandle( ref, @@ -54,6 +59,8 @@ export function ComposerEditor({ fontFamily, ...bodyText, paddingVertical: contentInsetVertical, + textAlign: writingDirection === "rtl" ? "right" : "left", + writingDirection, }, textStyle, ]} From 531241068e47d833213bfe61c4ba91bb34e90851 Mon Sep 17 00:00:00 2001 From: Amit Date: Wed, 26 Aug 2026 14:01:55 +0300 Subject: [PATCH 20/31] fix(web): read Hebrew blocks that open with a Latin prose label right-to-left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Hebrew paragraph that leads with a short English label ("Next step (ישן): מתחילים...") resolved LTR — the label's words are prose, not tech tokens, so the strip fallback never fired and the closing punctuation landed on the wrong side. When first-strong says LTR but most of the block's letters are strong-RTL, the letter majority now decides. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- apps/web/src/components/ChatMarkdown.test.tsx | 11 +++++++- apps/web/src/components/ChatMarkdown.tsx | 28 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index f8e55f72d0bb..26f8d85d4edf 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -162,10 +162,19 @@ describe("resolvedTextDirection", () => { it("keeps English text left-to-right, one Hebrew word or none", () => { expect(resolvedTextDirection("The word שלום means hello")).toBe("ltr"); expect(resolvedTextDirection("Hello world")).toBe("ltr"); - // Only tech tokens are discounted; leading English *words* still decide. + // Latin letters hold the majority here, so the leading English words decide. expect(resolvedTextDirection("Claude Code זה כלי")).toBe("ltr"); }); + it("reads a Hebrew sentence that opens with a Latin prose label right-to-left", () => { + expect( + resolvedTextDirection('Next step (ישן): "מתחילים לבנות תחנה 1, לאט. החוסמים: 3 קבצים."'), + ).toBe("rtl"); + expect(resolvedTextDirection("TL;DR: הפיצ׳ר עובד, נשאר רק לנקות את הקוד")).toBe("rtl"); + // A Latin-majority sentence quoting some Hebrew still reads left-to-right. + expect(resolvedTextDirection("The customer wrote שלום וברכה in the ticket")).toBe("ltr"); + }); + it("keeps Hebrew-first text right-to-left, unchanged", () => { expect(resolvedTextDirection("שלום, זה טקסט עם Claude Code בתוכו")).toBe("rtl"); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e9b001d0b563..6df8154fc851 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -518,10 +518,24 @@ function stripLtrTechTokens(text: string): string { return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); } -// First-strong, with one correction: text that *leads* with a Latin tech token -// but is otherwise RTL prose re-runs first-strong with those tokens stripped. -// A mostly-English text with one Hebrew word stays LTR — the stripped re-run -// still leads with its English words. +// The last-resort vote: which strong script owns most of the text's letters. +// Counted per letter (`\p{L}`), so neutral digits/punctuation and RTL combining +// marks (niqqud, harakat — marks, not letters) never tilt the tally. +function rtlLetterMajority(text: string): boolean { + let balance = 0; + for (const letter of text.match(/\p{L}/gu) ?? []) { + balance += STRONG_RTL_CHAR.test(letter) ? 1 : -1; + } + return balance > 0; +} + +// First-strong, with two corrections for RTL prose that *opens* with Latin: +// a leading tech token (URL, path, file name) never gets the first-strong vote, +// and a text whose letters are mostly RTL is RTL even when it leads with a +// Latin prose label — "**Next step (ישן):** מתחילים לבנות…" is a Hebrew +// sentence, and reading it LTR strands its closing punctuation on the wrong +// side. A mostly-English text with a few Hebrew words stays LTR — its Latin +// letters keep the majority. export function resolvedTextDirection(text: string): TextDirection { if (firstStrongDirection(text) === "rtl") { return "rtl"; @@ -529,7 +543,11 @@ export function resolvedTextDirection(text: string): TextDirection { if (!STRONG_RTL_CHAR.test(text)) { return "ltr"; } - return firstStrongDirection(stripLtrTechTokens(text)); + const stripped = stripLtrTechTokens(text); + if (firstStrongDirection(stripped) === "rtl") { + return "rtl"; + } + return rtlLetterMajority(stripped) ? "rtl" : "ltr"; } function hastTextContent(node: unknown): string { From bd3992dd237cca8fb4173f587f8280c67c7367cd Mon Sep 17 00:00:00 2001 From: Amit Date: Wed, 26 Aug 2026 14:01:56 +0300 Subject: [PATCH 21/31] fix(mobile): read Hebrew blocks that open with a Latin prose label right-to-left Mirrors the web fix: when first-strong says LTR but most of the block's letters are strong-RTL, the letter majority decides the direction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- .../src/nativeMarkdownText.test.ts | 11 +++++++- .../src/nativeMarkdownText.ts | 28 +++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts index 5315bb06ec75..4ca87fae4ae9 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts @@ -78,9 +78,18 @@ describe("resolvedTextDirection", () => { }); it("keeps plain English words before Hebrew left-to-right (no tech token)", () => { - // Only tech tokens are discounted; leading English *words* still decide. + // Latin letters hold the majority here, so the leading English words decide. expect(resolvedTextDirection("Claude Code זה כלי")).toBe("ltr"); }); + + it("reads a Hebrew sentence that opens with a Latin prose label right-to-left", () => { + expect( + resolvedTextDirection('Next step (ישן): "מתחילים לבנות תחנה 1, לאט. החוסמים: 3 קבצים."'), + ).toBe("rtl"); + expect(resolvedTextDirection("TL;DR: הפיצ׳ר עובד, נשאר רק לנקות את הקוד")).toBe("rtl"); + // A Latin-majority sentence quoting some Hebrew still reads left-to-right. + expect(resolvedTextDirection("The customer wrote שלום וברכה in the ticket")).toBe("ltr"); + }); }); describe("markdownBlockDirection", () => { diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index f86c577aa508..283f8db01677 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -99,10 +99,24 @@ function stripLtrTechTokens(text: string): string { return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); } -// First-strong, with one correction: text that *leads* with a Latin tech token -// but is otherwise RTL prose re-runs first-strong with those tokens stripped. -// A mostly-English text with one Hebrew word stays LTR — the stripped re-run -// still leads with its English words. +// The last-resort vote: which strong script owns most of the text's letters. +// Counted per letter (`\p{L}`), so neutral digits/punctuation and RTL combining +// marks (niqqud, harakat — marks, not letters) never tilt the tally. +function rtlLetterMajority(text: string): boolean { + let balance = 0; + for (const letter of text.match(/\p{L}/gu) ?? []) { + balance += STRONG_RTL_CHAR.test(letter) ? 1 : -1; + } + return balance > 0; +} + +// First-strong, with two corrections for RTL prose that *opens* with Latin: +// a leading tech token (URL, path, file name) never gets the first-strong vote, +// and a text whose letters are mostly RTL is RTL even when it leads with a +// Latin prose label — "**Next step (ישן):** מתחילים לבנות…" is a Hebrew +// sentence, and reading it LTR strands its closing punctuation on the wrong +// side. A mostly-English text with a few Hebrew words stays LTR — its Latin +// letters keep the majority. export function resolvedTextDirection(text: string): MarkdownWritingDirection { if (firstStrongDirection(text) === "rtl") { return "rtl"; @@ -110,7 +124,11 @@ export function resolvedTextDirection(text: string): MarkdownWritingDirection { if (!STRONG_RTL_CHAR.test(text)) { return "ltr"; } - return firstStrongDirection(stripLtrTechTokens(text)); + const stripped = stripLtrTechTokens(text); + if (firstStrongDirection(stripped) === "rtl") { + return "rtl"; + } + return rtlLetterMajority(stripped) ? "rtl" : "ltr"; } // Code and tables opt out of direction detection and stay LTR: their shape is not From 67a2dccf7ef55d2322ed02a7696d5026b8b1440d Mon Sep 17 00:00:00 2001 From: Amit Date: Wed, 26 Aug 2026 14:26:30 +0300 Subject: [PATCH 22/31] =?UTF-8?q?fix(web):=20finish=20Hebrew=20bidi=20rend?= =?UTF-8?q?ering=20=E2=80=94=20citations,=20list=20gutters,=20Latin-run=20?= =?UTF-8?q?isolates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three remaining right-to-left defects in chat markdown: - A Hebrew block whose Latin letters mostly sit in a quoted title or a parenthesized gloss ('PROFILE — הוספתי סעיף "Build-feedback call additions"') resolved LTR — quoted/parenthesized Latin-only spans no longer get the direction vote. - List direction was decided once per list, so a Hebrew item in a mixed list kept its bullet in the left gutter (a marker's side follows the direction property, which only a dir attribute flips). Every list item now carries its own dir, and the list pins its gutter side explicitly. - Inside RTL prose the bidi algorithm strands the neutrals around a Latin run on the wrong visual side ('"AIOS" סותר' flips its quotes). A rehype pass wraps each Latin run in ; anchors stay atomic. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- apps/web/src/components/ChatMarkdown.test.tsx | 48 +++++- apps/web/src/components/ChatMarkdown.tsx | 163 +++++++++++++++--- 2 files changed, 183 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 26f8d85d4edf..912969972389 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -54,16 +54,19 @@ describe("chat markdown text direction", () => { it("marks headings, lists, and quotes so their markers follow the text", () => { const html = render("# عنوان\n\n- عنصر\n\n> اقتباس"); expect(html).toContain(' '); - expect(html).toContain('
'); + // The list's gutter side is pinned from all its items together. + expect(html).toContain('
'); expect(html).toContain('
'); }); - it("marks only the outermost block, so a container still sees its own text", () => { - // A nested `dir` would be skipped when the browser resolves the outer - // `dir="auto"`, leaving the list LTR and its bullets in the wrong gutter. - const html = render("- عنصر\n\n> اقتباس"); - expect(html).toContain("- "); - expect(html).not.toContain("
- { + const html = render("- English item\n- פריט בעברית"); + expect(html).toContain('
'); + expect(html).toContain('
- '); + }); + + it("does not re-mark the blocks inside a claimed quote", () => { + const html = render("> اقتباس"); expect(html).not.toContain('
\n'); }); @@ -145,6 +148,28 @@ describe("chat markdown text direction", () => { expect(html).not.toContain('
'); }); + it("isolates a Latin run inside RTL prose so its quotes stay on the right sides", () => { + const html = render('הבוט "סותר את Kapso" לגמרי'); + expect(html).toContain("Kapso"); + }); + + it("keeps a compound Latin run whole inside one isolate", () => { + const html = render("דמו = U1+U2+U3+U5, ההסלמה אחרי"); + expect(html).toContain("U1+U2+U3+U5"); + }); + + it("leaves English blocks and code untouched by the isolation pass", () => { + const html = render("Plain English `code span` here"); + expect(html).not.toContain(""); + const rtlWithCode = render("תריץ `git status` עכשיו"); + expect(rtlWithCode).toContain('
git status'); + }); + + it("keeps a link atomic inside RTL prose instead of slicing it into isolates", () => { + const html = render("הקישור https://claude.ai/docs זה טוב"); + expect(html).not.toContain("https"); + }); + it("gives a table opening with a tech-token cell its direction from its prose", () => { const html = render("| `id.ts` | שם |\n| --- | --- |\n| `a.py` | קובץ |"); expect(html).toContain('dir="rtl"'); @@ -175,6 +200,15 @@ describe("resolvedTextDirection", () => { expect(resolvedTextDirection("The customer wrote שלום וברכה in the ticket")).toBe("ltr"); }); + it("discounts quoted and parenthesized Latin citations from the vote", () => { + expect(resolvedTextDirection('PROFILE — הוספתי סעיף "Build-feedback call additions"')).toBe( + "rtl", + ); + expect(resolvedTextDirection("P1 — אסטרטגיות (product-lens):")).toBe("rtl"); + // A Hebrew quotation inside English prose keeps its vote — still LTR. + expect(resolvedTextDirection('They titled it "ברוכים הבאים" and moved on quickly')).toBe("ltr"); + }); + it("keeps Hebrew-first text right-to-left, unchanged", () => { expect(resolvedTextDirection("שלום, זה טקסט עם Claude Code בתוכו")).toBe("rtl"); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6df8154fc851..c56172a79fff 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -209,9 +209,105 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkTextDirection, ] satisfies NonNullable; +// Inside a right-to-left block the bidi algorithm hands the neutrals around a +// Latin run — quotes, commas, a plus sign — to whichever strong run is nearer, +// which strands them on the wrong visual side ('"AIOS" סותר' flips its quotes, +// "U1+U2+U3, ההסלמה" splits the comma off its run). Wrapping each Latin run in +// a isolates it, so the punctuation around it resolves against the +// Hebrew it belongs to. A run may span several words joined by thin neutrals +// ("speed-to-lead", "U1+U2+U3+U5", "OpenAI export"); a connector is only +// swallowed when another Latin word follows it, so sentence-final punctuation +// stays outside the isolate. +const LATIN_RUN = /\p{Script=Latin}[\p{Script=Latin}\d]*(?:[ +&/.:'@_-]+[\p{Script=Latin}\d]+)*/gu; +const BIDI_LEAF_TAG_NAMES = new Set([ + "p", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "td", + "th", + "li", + "dt", + "dd", +]); +// Links stay atomic: an anchor's text is usually a URL or a title whose own +// strong letters already resolve as one run — slicing it into isolates would +// let the neutrals between the pieces reorder against the paragraph. +const BIDI_SKIP_TAG_NAMES = new Set(["a", "code", "pre", "bdi", "bdo"]); + +type HastNode = { + type?: string; + tagName?: string; + value?: string; + properties?: Record ; + children?: HastNode[]; +}; + +function splitTextIntoIsolates(value: string): HastNode[] { + const out: HastNode[] = []; + let last = 0; + for (const match of value.matchAll(LATIN_RUN)) { + const start = match.index ?? 0; + if (start > last) { + out.push({ type: "text", value: value.slice(last, start) }); + } + out.push({ + type: "element", + tagName: "bdi", + properties: {}, + children: [{ type: "text", value: match[0] }], + }); + last = start + match[0].length; + } + if (last === 0) { + return [{ type: "text", value }]; + } + if (last < value.length) { + out.push({ type: "text", value: value.slice(last) }); + } + return out; +} + +function isolateLatinRuns(node: HastNode) { + if (!node.children) { + return; + } + node.children = node.children.flatMap((child): HastNode[] => { + if (child.type === "text" && typeof child.value === "string") { + return splitTextIntoIsolates(child.value); + } + if (child.type === "element" && BIDI_SKIP_TAG_NAMES.has(child.tagName ?? "")) { + return [child]; + } + isolateLatinRuns(child); + return [child]; + }); +} + +function rehypeIsolateLatinRuns() { + return (tree: HastNode) => { + const visit = (node: HastNode) => { + if ( + node.type === "element" && + BIDI_LEAF_TAG_NAMES.has(node.tagName ?? "") && + resolvedTextDirection(hastTextContent(node)) === "rtl" + ) { + isolateLatinRuns(node); + return; + } + node.children?.forEach(visit); + }; + visit(tree); + }; +} + const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], + rehypeIsolateLatinRuns, ] satisfies NonNullable ; /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ @@ -358,7 +454,7 @@ function remarkTagInlineCode() { const AUTO_DIRECTION_NODE_TYPES = new Set([ "blockquote", "heading", - "list", + "listItem", "paragraph", "tableCell", ]); @@ -392,14 +488,13 @@ function setDirection(node: MarkdownAstNode, dir: "auto" | "ltr" | "rtl") { function remarkTextDirection() { return (tree: MarkdownAstNode) => { // `dir="auto"` reads the first strong character of an element's *own* text - // and skips any descendant that carries its own `dir`. So only the outermost - // block of a run gets marked: marking a list and its items both would leave - // the list itself with no text to judge, fall back to LTR, and paint the - // bullets of an RTL item into a gutter that is no longer on that side. - // - // The cost is that one list reads in one direction. A list that mixes an - // Arabic item with an English one takes the direction of its first item, - // which is the trade for markers that stay next to the text they label. + // and skips any descendant that carries its own `dir`. Blocks below a marked + // one are normally left alone (the `plaintext` CSS lets each resolve its own + // text), with two exceptions: every list item is marked so a Hebrew item in + // an English list still gets its bullet in the right-hand gutter (a marker's + // side follows the `direction` property, which only a `dir` attribute + // flips), and a leaf whose heuristic disagrees with its own first-strong + // scan is pinned, since `plaintext` cannot discount a leading Latin token. const visit = (node: MarkdownAstNode, insideAutoBlock: boolean) => { const type = node.type ?? ""; if (LTR_DIRECTION_NODE_TYPES.has(type)) { @@ -410,14 +505,24 @@ function remarkTextDirection() { return; } + if (type === "list") { + // Each item claims its own direction below, which leaves the list + // element itself no text for `dir="auto"` to judge — so its gutter + // side is pinned explicitly from all the items together. + if (!insideAutoBlock) { + setDirection(node, resolvedTextDirection(directionDetectionText(node))); + } + node.children?.forEach((child) => visit(child, insideAutoBlock)); + return; + } + // A GitHub alert is rendered as a titled callout rather than a quote, and // its own renderer builds that chrome from scratch. Claiming the block // here would strand its body: the `dir` never reaches the callout, and the // paragraphs inside it would have been skipped as already-covered. const isAlertBlockquote = type === "blockquote" && node.data?.hProperties?.dataAlert != null; - const isAutoBlock = - !insideAutoBlock && !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type); - if (isAutoBlock) { + const isDirectionBlock = !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type); + if (isDirectionBlock && !insideAutoBlock) { // `dir="auto"` (and the `plaintext` CSS) is the browser's own first-strong // scan, which cannot discount a leading Latin tech token — "server.py זה // הקובץ" resolves LTR. When the heuristic disagrees with plain first-strong, @@ -431,8 +536,21 @@ function remarkTextDirection() { ? "rtl" : "auto", ); + } else if (isDirectionBlock && insideAutoBlock) { + // Inside a claimed block the `plaintext` CSS still re-resolves each + // leaf from its own text — pin just the leaves whose leading Latin + // token would make that scan misread otherwise-RTL prose. + const detectionText = directionDetectionText(node); + if ( + firstStrongDirection(detectionText) === "ltr" && + resolvedTextDirection(detectionText) === "rtl" + ) { + setDirection(node, "rtl"); + } } - node.children?.forEach((child) => visit(child, insideAutoBlock || isAutoBlock)); + node.children?.forEach((child) => + visit(child, insideAutoBlock || (isDirectionBlock && !insideAutoBlock)), + ); }; visit(tree, false); @@ -505,16 +623,19 @@ export function firstStrongDirection(text: string): TextDirection { return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; } -// The tech tokens a Hebrew sentence often *opens* with — a URL, an inline-code -// span, a path, a file name ("server.py זה הקובץ הראשי"). Their Latin letters -// are identifiers, not prose, so they must not get the first-strong vote. -// Mirrors the mobile app's pattern (each app keeps its own copy — no cross-app -// imports) and stripLeadingLTR from the claude-desktop-rtl-patch. -const LTR_TECH_TOKEN = /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b/gu; +// The Latin spans that must not get the direction vote: tech tokens a Hebrew +// sentence often *opens* with (a URL, an inline-code span, a path, a file name +// — "server.py זה הקובץ הראשי"), plus quoted or parenthesized Latin — a cited +// title or gloss ('הוספתי סעיף "Build-feedback call additions"', "אסטרטגיות +// (product-lens)") names a thing rather than continuing the prose. Mirrors the +// mobile app's pattern (each app keeps its own copy — no cross-app imports) +// and stripLeadingLTR from the claude-desktop-rtl-patch. +const LTR_TECH_TOKEN = + /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b|"[^"\n]+"|[“«][^”»\n]+[”»]|\([^()\n]+\)/gu; function stripLtrTechTokens(text: string): string { - // A token carrying its own strong-RTL letters (an RTL slash pair like כן/לא) - // is prose, not a tech identifier — it keeps its vote. + // A span carrying its own strong-RTL letters (an RTL slash pair like כן/לא, + // a Hebrew quotation) is prose, not a citation — it keeps its vote. return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); } From e52ba1f1e60b0c5fba5d6945e2dcf8dcef378003 Mon Sep 17 00:00:00 2001 From: Amit Date: Wed, 26 Aug 2026 14:26:31 +0300 Subject: [PATCH 23/31] =?UTF-8?q?fix(mobile):=20finish=20Hebrew=20bidi=20r?= =?UTF-8?q?endering=20=E2=80=94=20citations,=20per-item=20lists,=20Latin-r?= =?UTF-8?q?un=20isolates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the web fixes: quoted/parenthesized Latin citations lose the direction vote, each list item resolves its own writing direction so mixed lists keep markers beside their text, and Latin runs inside RTL paragraphs are wrapped in LRI…PDI isolates (the same isolate inline code already uses) so surrounding punctuation stays on the right side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- .../src/nativeMarkdownText.test.ts | 41 ++++++++++--- .../src/nativeMarkdownText.ts | 61 ++++++++++++++----- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts index 4ca87fae4ae9..b037d6b8c9cb 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.test.ts @@ -90,6 +90,15 @@ describe("resolvedTextDirection", () => { // A Latin-majority sentence quoting some Hebrew still reads left-to-right. expect(resolvedTextDirection("The customer wrote שלום וברכה in the ticket")).toBe("ltr"); }); + + it("discounts quoted and parenthesized Latin citations from the vote", () => { + expect(resolvedTextDirection('PROFILE — הוספתי סעיף "Build-feedback call additions"')).toBe( + "rtl", + ); + expect(resolvedTextDirection("P1 — אסטרטגיות (product-lens):")).toBe("rtl"); + // A Hebrew quotation inside English prose keeps its vote — still LTR. + expect(resolvedTextDirection('They titled it "ברוכים הבאים" and moved on quickly')).toBe("ltr"); + }); }); describe("markdownBlockDirection", () => { @@ -136,7 +145,7 @@ describe("nativeMarkdownDocumentRuns direction", () => { expect(english?.writingDirection).toBe("ltr"); }); - it("gives a Hebrew list one RTL direction, markers included", () => { + it("gives every list item its own direction, markers included", () => { const runs = nativeMarkdownDocumentRuns( document({ type: "list", @@ -147,12 +156,13 @@ describe("nativeMarkdownDocumentRuns direction", () => { ], }), ); - // The outermost list decides once; every run inherits (web: only the - // outermost block carries dir="auto", items inherit). - for (const run of runs) { - expect(run.writingDirection).toBe("rtl"); - } - expect(runs.some((run) => run.role === "list-marker")).toBe(true); + // Mixed lists keep each marker beside the text it labels (web: per-item dir). + const hebrewItem = runs.find((run) => run.text.includes("פריט")); + const englishItem = runs.find((run) => run.text.includes("English")); + expect(hebrewItem?.writingDirection).toBe("rtl"); + expect(englishItem?.writingDirection).toBe("ltr"); + const markers = runs.filter((run) => run.role === "list-marker"); + expect(markers.map((run) => run.writingDirection)).toEqual(["rtl", "ltr"]); }); it("inherits the outer direction into nested lists", () => { @@ -180,6 +190,23 @@ describe("nativeMarkdownDocumentRuns direction", () => { } }); + it("isolates Latin runs inside RTL text so surrounding punctuation stays put", () => { + const runs = nativeMarkdownDocumentRuns( + document(paragraph(text('הבוט "סותר את Kapso" וגם U1+U2+U3+U5, ההסלמה'))), + ); + const body = runs.find((run) => run.text.includes("Kapso")); + expect(body?.text).toContain("Kapso"); + expect(body?.text).toContain("U1+U2+U3+U5"); + // The closing quote and the comma stay outside the isolates. + expect(body?.text).toContain('Kapso"'); + expect(body?.text).toContain(", ההסלמה"); + }); + + it("leaves LTR text without isolates", () => { + const runs = nativeMarkdownDocumentRuns(document(paragraph(text("Plain English text here")))); + expect(runs[0]?.text).not.toContain(""); + }); + it("marks a Hebrew heading RTL", () => { const runs = nativeMarkdownDocumentRuns( document({ type: "heading", level: 2, children: [text("כותרת בעברית")] }), diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 283f8db01677..b854f6e05d22 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -86,16 +86,19 @@ export function firstStrongDirection(text: string): MarkdownWritingDirection { return letter && STRONG_RTL_CHAR.test(letter) ? "rtl" : "ltr"; } -// The tech tokens a Hebrew sentence often *opens* with — a URL, an inline-code -// span, a path, a file name ("server.py זה הקובץ הראשי"). Their Latin letters -// are identifiers, not prose, so they must not get the first-strong vote. -// Mirrors the web app's pattern (each app keeps its own copy — no cross-app -// imports) and stripLeadingLTR from the claude-desktop-rtl-patch. -const LTR_TECH_TOKEN = /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b/gu; +// The Latin spans that must not get the direction vote: tech tokens a Hebrew +// sentence often *opens* with (a URL, an inline-code span, a path, a file name +// — "server.py זה הקובץ הראשי"), plus quoted or parenthesized Latin — a cited +// title or gloss ('הוספתי סעיף "Build-feedback call additions"', "אסטרטגיות +// (product-lens)") names a thing rather than continuing the prose. Mirrors the +// web app's pattern (each app keeps its own copy — no cross-app imports) and +// stripLeadingLTR from the claude-desktop-rtl-patch. +const LTR_TECH_TOKEN = + /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b|"[^"\n]+"|[“«][^”»\n]+[”»]|\([^()\n]+\)/gu; function stripLtrTechTokens(text: string): string { - // A token carrying its own strong-RTL letters (an RTL slash pair like כן/לא) - // is prose, not a tech identifier — it keeps its vote. + // A span carrying its own strong-RTL letters (an RTL slash pair like כן/לא, + // a Hebrew quotation) is prose, not a citation — it keeps its vote. return text.replace(LTR_TECH_TOKEN, (token) => (STRONG_RTL_CHAR.test(token) ? token : " ")); } @@ -364,13 +367,35 @@ function nodeTextContent(node: MarkdownNode): string { return (node.children ?? []).map(nodeTextContent).join(""); } +// Inside a right-to-left paragraph the bidi algorithm hands the neutrals around +// a Latin run — quotes, commas, a plus sign — to whichever strong run is +// nearer, which strands them on the wrong visual side ('"AIOS" סותר' flips its +// quotes, "U1+U2+U3, ההסלמה" splits the comma off its run). Isolating each run +// (LRI … PDI, the same isolate inline code uses) lets that punctuation resolve +// against the Hebrew it belongs to. A run may span several words joined by +// thin neutrals ("speed-to-lead", "U1+U2+U3+U5", "OpenAI export"); a connector +// is only swallowed when another Latin word follows it, so sentence-final +// punctuation stays outside the isolate. Mirrors the web app's pass. +const LATIN_RUN = /\p{Script=Latin}[\p{Script=Latin}\d]*(?:[ +&/.:'@_-]+[\p{Script=Latin}\d]+)*/gu; + +function isolateLatinRuns(text: string): string { + return text.replace(LATIN_RUN, (run) => `\u2066${run}\u2069`); +} + function appendNode( runs: NativeMarkdownTextRun[], node: MarkdownNode, context: RunContext, ): NativeMarkdownTextRun[] { switch (node.type) { - case "text": + case "text": { + const content = textNodeContent(nodeTextContent(node)); + return appendRun( + runs, + context.writingDirection === "rtl" ? isolateLatinRuns(content) : content, + context, + ); + } case "math_inline": return appendRun(runs, textNodeContent(nodeTextContent(node)), context); case "html_inline": @@ -575,9 +600,10 @@ function appendList( runs: NativeMarkdownTextRun[], node: MarkdownNode, depth: number, - // A list takes one direction as a whole — the outermost block of a run decides - // and items inherit, so the markers stay on the same side as the text they label. - writingDirection: MarkdownWritingDirection, + // Each item resolves its own direction (a Hebrew item in an English list + // still gets its marker on the right, mirroring the web's per-item `dir`), + // unless the list sits inside an already-claimed block — then it inherits. + inheritedDirection?: MarkdownWritingDirection, ): NativeMarkdownTextRun[] { const ordered = node.ordered ?? false; const start = node.start ?? 1; @@ -609,7 +635,14 @@ function appendList( : marker; const markerColumnWidth = child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24; - appendListItem(runs, child, alignedMarker, depth, markerColumnWidth, writingDirection); + appendListItem( + runs, + child, + alignedMarker, + depth, + markerColumnWidth, + inheritedDirection ?? markdownBlockDirection(child), + ); } return runs; } @@ -736,7 +769,7 @@ function appendDocumentBlock( return appendBlockTerminator(runs, context); } case "list": - return appendList(runs, node, depth + 1, direction ?? markdownBlockDirection(node)); + return appendList(runs, node, depth + 1, direction); case "blockquote": return appendQuoteBlock(runs, node, depth, direction ?? markdownBlockDirection(node)); case "code_block": { From 0a113c5113500d255cbf9837398106eaad00bb74 Mon Sep 17 00:00:00 2001 From: Amit Date: Wed, 26 Aug 2026 14:38:56 +0300 Subject: [PATCH 24/31] test(web): accept the button variant of the LTR-pinned file chip Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- apps/web/src/components/ChatMarkdown.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index ae8aeef749cf..6f282e860b90 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -342,7 +342,9 @@ describe("chat markdown text direction", () => { // The `code` renderer swaps the chip in for the ` ` it // replaces, so a path in an Arabic sentence keeps its own reading order. const html = render("عدّل `src/main.ts` من فضلك."); - expect(html).toContain(']* dir="ltr"/); }); it("gives a table its base direction from its own content, cells still self-resolve", () => { From a878c254a74a3a6140dabd60283d5275c28c16b3 Mon Sep 17 00:00:00 2001 From: AmitDate: Wed, 26 Aug 2026 14:44:33 +0300 Subject: [PATCH 25/31] fix(web): give question-panel prose its own text direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AskUserQuestion panel rendered its header, question, option labels and descriptions as plain LTR text, so a Hebrew question aligned left and mixed strings like 'קידום ה-venture' scrambled. Each string now resolves its own direction with the chat heuristic; the panel chrome stays put. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- .../chat/ComposerPendingUserInputPanel.tsx | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index c121110bb3f0..2de5eb2d144a 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -7,6 +7,7 @@ import { } from "../../pendingUserInput"; import { CheckIcon, ChevronDownIcon } from "lucide-react"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { resolvedTextDirection } from "../ChatMarkdown"; import { cn } from "~/lib/utils"; interface PendingUserInputPanelProps { @@ -187,7 +188,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"} className="group -my-1 flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/35 focus-visible:ring-1 focus-visible:ring-primary/25" > - + {activeQuestion.header} {prompt.questions.length > 1 ? ( @@ -199,7 +203,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( counter, so the question itself is echoed here as a one-line reminder of what is being asked. */} {isCollapsed ? ( - + {activeQuestion.question} ) : null} @@ -219,7 +226,15 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( that padding or their focus rings get shaved off at the edges. */} -{activeQuestion.question}
+ {/* The question and its options are agent-authored prose, so each + string resolves its own direction — a Hebrew question reads and + aligns right-to-left while the panel chrome stays put. */} ++ {activeQuestion.question} +
{activeQuestion.multiSelect ? (Select one or more options.
) : null} @@ -243,9 +258,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const content = ( <>- {option.label} + + {option.label} + {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}{isSelected ? ( From 7846dd28a48bcf05d774ec5aecaf7526940c4b59 Mon Sep 17 00:00:00 2001 From: AmitDate: Wed, 26 Aug 2026 14:44:34 +0300 Subject: [PATCH 26/31] fix(mobile): give question-card prose its own text direction Mirrors the web fix for the pending-user-input card: agent-authored header, question, labels and descriptions resolve their own writing direction (RTL gets writingDirection + right alignment). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- .../features/threads/PendingUserInputCard.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 4b5a93cd1f75..ee4eb36083c7 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -18,6 +18,16 @@ import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; +import { resolvedTextDirection } from "@t3tools/mobile-markdown-text/markdown"; + +// The question and its options are agent-authored prose, so each string +// resolves its own direction — a Hebrew question reads and aligns +// right-to-left while the card chrome stays put. +function proseDirectionStyle(text: string) { + return resolvedTextDirection(text) === "rtl" + ? ({ writingDirection: "rtl", textAlign: "right" } as const) + : undefined; +} import { useThemeColor } from "../../lib/useThemeColor"; import { isPendingUserInputOptionSelected, @@ -251,10 +261,16 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { const draft = props.drafts[question.id]; return ( - + {question.header} -+ {question.question} @@ -281,6 +297,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { > {description ? ( - + {description} ) : null} From bd02b4d9074d841ec57527815420ce51b2158b55 Mon Sep 17 00:00:00 2001 From: AmitDate: Wed, 26 Aug 2026 14:49:23 +0300 Subject: [PATCH 27/31] fix(web): align question-panel prose with its own direction text-left on the trigger and option buttons overrode the dir-based alignment of the Hebrew labels inside; text-start resolves per element, so RTL strings align right while English options stay left. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01TTbkTygggTPvtRXmu1VNW4 --- .../web/src/components/chat/ComposerPendingUserInputPanel.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 2de5eb2d144a..3bdd5a0575aa 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -186,7 +186,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( isCollapsed ? "Show the question and its options" : "Hide the question and its options" } data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"} - className="group -my-1 flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/35 focus-visible:ring-1 focus-visible:ring-primary/25" + className="group -my-1 flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-start outline-none transition-colors duration-150 hover:bg-muted/35 focus-visible:ring-1 focus-visible:ring-primary/25" > Date: Tue, 15 Sep 2026 12:59:14 +0300 Subject: [PATCH 28/31] fix(rtl): address Macroscope review findings on PR #11868 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web: FIRST_LETTER/STRONG_RTL_CHAR now recognize RLM (U+200F) so a block opening with the mark resolves RTL - web: LTR_TECH_TOKEN's slash-path alternative is anchored to token boundaries, avoiding quadratic backtracking on a long slash-free token - web: GitHub alert direction is computed explicitly and passed through as data instead of relying on the alert container's native `dir="auto"`, which the alert's own annotated paragraphs were silently defeating - web: sidebar search result titles get `text-start` so `dir="auto"` isn't overridden by the row's inherited `text-left` - mobile: `html_inline` now isolates Latin runs in RTL text, matching the `text` case - mobile: Latin-run isolation no longer wraps the token right after a `$`, so skill mentions like `$ui` keep matching - mobile: `inlineHtmlText`'s tag stripper is quote-aware, so a `>` inside a quoted attribute no longer truncates direction-detection text - mobile: list item markers resolve direction per item instead of once for the whole list, so a mixed-direction list places each marker correctly - mobile: copying inline code from RTL text no longer leaks the invisible LRI/PDI bidi isolate marks into the clipboard, on Android and iOS Reviewed but not applied: the bot's STRONG_RTL_CHAR-too-broad finding on mobile nativeMarkdownText.ts — checked the actual code points and the range is already 0x0590-0x08FF (same as web), not the wider range the finding described; Devanagari is not affected. Co-Authored-By: Claude Sonnet 5 --- .../T3MarkdownTextSelectionModule.kt | 14 ++++- .../t3-markdown-text/ios/T3MarkdownText.mm | 34 +++++++++++- .../src/NativeMarkdownBlock.ios.tsx | 12 ++--- .../src/nativeMarkdownText.ts | 20 +++++-- apps/web/src/components/ChatMarkdown.tsx | 54 +++++++++++++++---- apps/web/src/components/Sidebar.tsx | 2 +- 6 files changed, 112 insertions(+), 24 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index af8675831f2d..2c11ed5f1d9f 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -17,20 +17,30 @@ import kotlin.math.max import kotlin.math.min private const val OBJECT_REPLACEMENT_CHARACTER = "\uFFFC" +// The markdown renderer wraps inline code in an RTL paragraph with an LTR bidi +// isolate (LRI \u2066 \u2026 PDI \u2069) so it renders left-to-right without a Text +// ref call per span. Invisible in the UI, but must not leak into a paste. +private val BIDI_ISOLATE_CHARACTERS = setOf('\u2066', '\u2069') private fun copyTextWithoutInlineImages( text: CharSequence, start: Int, end: Int ): String { - if (text !is Spanned) return text.subSequence(start, end).toString() + if (text !is Spanned) { + return buildString { + for (index in start until end) { + if (text[index] !in BIDI_ISOLATE_CHARACTERS) append(text[index]) + } + } + } return buildString { for (index in start until end) { val isInlineImage = text[index].toString() == OBJECT_REPLACEMENT_CHARACTER && text.getSpans(index, index + 1, ReplacementSpan::class.java).isNotEmpty() - if (!isInlineImage) append(text[index]) + if (!isInlineImage && text[index] !in BIDI_ISOLATE_CHARACTERS) append(text[index]) } } } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 9cf27cc554a1..88491b974545 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -197,6 +197,38 @@ - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer return coordinator; } +// The markdown renderer wraps inline code in an RTL paragraph with an LTR bidi +// isolate (LRI/PDI) and file/skill placeholders with the object-replacement +// character — both invisible in the UI, neither meant to leak into a paste. +@interface T3MarkdownSelectableTextView : UITextView +@end + +@implementation T3MarkdownSelectableTextView + +- (void)copy:(id)sender +{ + NSRange range = self.selectedRange; + if (range.length == 0) { + [super copy:sender]; + return; + } + + NSString *selectedText = [self.attributedText attributedSubstringFromRange:range].string; + NSMutableString *sanitized = [selectedText mutableCopy]; + [sanitized replaceOccurrencesOfString:@"\uFFFC" withString:@"" options:0 range:NSMakeRange(0, sanitized.length)]; + [sanitized replaceOccurrencesOfString:@"\u2066" withString:@"" options:0 range:NSMakeRange(0, sanitized.length)]; + [sanitized replaceOccurrencesOfString:@"\u2069" withString:@"" options:0 range:NSMakeRange(0, sanitized.length)]; + + if ([sanitized isEqualToString:selectedText]) { + [super copy:sender]; + return; + } + + UIPasteboard.generalPasteboard.string = sanitized; +} + +@end + @interface T3MarkdownText () @end @@ -231,7 +263,7 @@ - (instancetype)initWithFrame:(CGRect)frame self.contentView = _view; self.clipsToBounds = true; - _textView = [[UITextView alloc] init]; + _textView = [[T3MarkdownSelectableTextView alloc] init]; _attachmentImages = [[NSMutableDictionary alloc] init]; _pendingAttachmentUris = [[NSMutableSet alloc] init]; _textView.scrollEnabled = false; diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 1829abab650a..c668a0bb4672 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -526,11 +526,6 @@ function NativeList(props: { const ordered = props.node.ordered ?? false; const start = props.node.start ?? 1; const nested = props.depth > 0; - // The list takes one direction as a whole — inherited from the enclosing block, - // or resolved from the list's own first strong letter — so an RTL list paints - // every marker on the right of the text it labels. - const direction = props.direction ?? markdownBlockDirection(props.node); - const rtl = direction === "rtl"; return ( {(props.node.children ?? []).map((item, index) => { + // Each item resolves its own direction — inherited from the enclosing + // block, or from the item's own first strong letter — so a Hebrew item + // in an English list still gets its marker on the right, and vice versa. + const itemDirection = props.direction ?? markdownBlockDirection(item); + const rtl = itemDirection === "rtl"; const taskMarker = item.type === "task_list_item"; const marker = taskMarker ? item.checked @@ -590,7 +590,7 @@ function NativeList(props: { highlightCode={props.highlightCode} onLinkPress={props.onLinkPress} depth={props.depth + 1} - direction={direction} + direction={itemDirection} compact /> ))} diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 285003020e6a..3240fd1ed597 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -220,11 +220,16 @@ function textNodeContent(value: string): string { return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, ""); } +// Tag-stripping regex that doesn't stop at a `>` inside a quoted attribute +// value (e.g. ``), which would otherwise leak +// attribute text into the direction-detection scan. +const HTML_TAG = /<(?:[^>"']|"[^"]*"|'[^']*')*>/g; + function inlineHtmlText(value: string): string { if (/^
$/i.test(value.trim())) { return "\n"; } - return decodeHtmlEntities(value.replace(/<[^>]+>/g, "")); + return decodeHtmlEntities(value.replace(HTML_TAG, "")); } function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean { @@ -381,7 +386,8 @@ function nodeTextContent(node: MarkdownNode): string { // thin neutrals ("speed-to-lead", "U1+U2+U3+U5", "OpenAI export"); a connector // is only swallowed when another Latin word follows it, so sentence-final // punctuation stays outside the isolate. Mirrors the web app's pass. -const LATIN_RUN = /\p{Script=Latin}[\p{Script=Latin}\d]*(?:[ +&/.:'@_-]+[\p{Script=Latin}\d]+)*/gu; +const LATIN_RUN = + /(? `\u2066${run}\u2069`); @@ -403,8 +409,14 @@ function appendNode( } case "math_inline": return appendRun(runs, textNodeContent(nodeTextContent(node)), context); - case "html_inline": - return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context); + case "html_inline": { + const content = inlineHtmlText(nodeTextContent(node)); + return appendRun( + runs, + context.writingDirection === "rtl" ? isolateLatinRuns(content) : content, + context, + ); + } case "code_inline": { // Inline code keeps its left-to-right shape even inside an RTL paragraph // (the web pins `code` to LTR with CSS). Attributed strings have no diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3090e5e14201..6f040b207d4a 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -440,7 +440,11 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema.attributes, "*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta", "dataInlineCode"], - blockquote: [...(defaultSchema.attributes?.blockquote ?? []), "dataAlert"], + blockquote: [ + ...(defaultSchema.attributes?.blockquote ?? []), + "dataAlert", + "dataAlertDirection", + ], div: [...(defaultSchema.attributes?.div ?? []), ...CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES], a: [...(defaultSchema.attributes?.a ?? []), "dataPullRequestAutolink"], img: [ @@ -793,10 +797,23 @@ function remarkTextDirection() { } // A GitHub alert is rendered as a titled callout rather than a quote, and - // its own renderer builds that chrome from scratch. Claiming the block - // here would strand its body: the `dir` never reaches the callout, and the - // paragraphs inside it would have been skipped as already-covered. + // its own renderer builds that chrome from scratch. Its outer container + // hardcodes `dir="auto"`, but the browser's native scan for that skips + // any descendant that itself carries an explicit `dir` — and the alert's + // own paragraphs need one (below) so their own text aligns correctly. + // That leaves the container's native scan nothing to resolve from, so + // its direction is computed here instead and threaded through as data + // for the renderer to apply directly. const isAlertBlockquote = type === "blockquote" && node.data?.hProperties?.dataAlert != null; + if (isAlertBlockquote) { + node.data = { + ...node.data, + hProperties: { + ...node.data?.hProperties, + dataAlertDirection: resolvedTextDirection(directionDetectionText(node)), + }, + }; + } const isDirectionBlock = !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type); if (isDirectionBlock && !insideAutoBlock) { // `dir="auto"` (and the `plaintext` CSS) is the browser's own first-strong @@ -887,11 +904,14 @@ function readInitialWordWrapSetting(): boolean { } // Strong-RTL code points: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic and -// their extensions/presentation forms, plus the astral RTL blocks (Phoenician … Adlam). +// their extensions/presentation forms, the RTL formatting mark (RLM), 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; + /[\u0590-\u08FF\u200F\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; +// RLM/ALM (the strong-direction formatting marks) count too — a block that opens +// with one is asserting its direction explicitly. +const FIRST_LETTER = /[\p{L}\u061C\u200F]/u; // The direction a block of text renders in — what `dir="auto"` would resolve. export function firstStrongDirection(text: string): TextDirection { @@ -907,7 +927,7 @@ export function firstStrongDirection(text: string): TextDirection { // mobile app's pattern (each app keeps its own copy — no cross-app imports) // and stripLeadingLTR from the claude-desktop-rtl-patch. const LTR_TECH_TOKEN = - /https?:\/\/\S+|`[^`\n]+`|\S*[/\\]\S+|\b\w+\.\w{1,5}\b|"[^"\n]+"|[“«][^”»\n]+[”»]|\([^()\n]+\)/gu; + /https?:\/\/\S+|`[^`\n]+`|(?:^|(?<=\s))\S*[/\\]\S+|\b\w+\.\w{1,5}\b|"[^"\n]+"|[“«][^”»\n]+[”»]|\([^()\n]+\)/gu; function stripLtrTechTokens(text: string): string { // A span carrying its own strong-RTL letters (an RTL slash pair like כן/לא, @@ -2975,12 +2995,26 @@ const CHAT_MARKDOWN_COMPONENTS = { } // 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. + // + // The container's direction is computed by remarkTextDirection rather than + // left to a native `dir="auto"` scan: the body paragraphs below carry their + // own explicit `dir` (for their own alignment), and the browser's `auto` + // resolution skips descendants that already have one — leaving nothing for + // a native scan on this container to resolve from. + const alertDirection = + String((props as Record)["data-alert-direction"] ?? "") === "rtl" + ? "rtl" + : "ltr"; return ( - +{children} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a2586fca2d29..bfb5f4259542 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2072,7 +2072,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { {props.project ? (
{/* 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. */} + direction — the body decides the side and the row follows it. */} {alert.label} ) : null} - + {thread.title} From 8dad01c79ac91b98828f88f00feee1ea9e47b40d Mon Sep 17 00:00:00 2001 From: Amit Date: Tue, 15 Sep 2026 13:20:34 +0300 Subject: [PATCH 29/31] fix(web): resolve nested list items' own direction, not just top-level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CodeRabbit review on #11868 flagged that remarkTextDirection only let top-level list items resolve their own text direction; items in a nested sub-list inherited insideAutoBlock from their claimed ancestor and only got pinned when the leading-Latin heuristic disagreed, otherwise going unmarked and inheriting the wrong gutter side. Thread a pinnedRtl flag through the tree walk: it's only true under an ancestor explicitly pinned dir="rtl" by the heuristic override, where CSS inheritance is intentional. Everywhere else — including nested lists under a plain "auto" ancestor — items resolve independently, same as a top-level list. Co-Authored-By: Claude Sonnet 5 --- apps/web/src/components/ChatMarkdown.test.tsx | 7 ++++ apps/web/src/components/ChatMarkdown.tsx | 35 +++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 230adc4498f8..2f443f88342f 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -777,6 +777,13 @@ describe("chat markdown text direction", () => { expect(html).toContain(' - '); }); + it("gives a nested list's items their own direction too, not just the top level", () => { + // A Hebrew item nested under an English top-level item must still get its + // own `dir`, or its marker inherits the (wrong) English sub-list side. + const html = render("- English top\n - English sub\n - פריט בעברית"); + expect(html).toContain('
- פריט בעברית
'); + }); + it("does not re-mark the blocks inside a claimed quote", () => { const html = render("> اقتباس"); expect(html).not.toContain('\n'); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6f040b207d4a..f49ecd90ca20 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -775,13 +775,19 @@ function remarkTextDirection() { // side follows the `direction` property, which only a `dir` attribute // flips), and a leaf whose heuristic disagrees with its own first-strong // scan is pinned, since `plaintext` cannot discount a leading Latin token. - const visit = (node: MarkdownAstNode, insideAutoBlock: boolean) => { + // `pinnedRtl` tracks whether the nearest claimed ancestor was forced to an + // explicit `dir="rtl"` (a heuristic override, not the default `"auto"`). + // That pin is deliberate — the `[dir="rtl"] … li` CSS rule means nested + // content should inherit it rather than recompute its own direction. + // Everywhere else — including under a plain `"auto"` ancestor — a nested + // list's own items still resolve independently, same as a top-level one. + const visit = (node: MarkdownAstNode, insideAutoBlock: boolean, pinnedRtl: boolean) => { const type = node.type ?? ""; if (LTR_DIRECTION_NODE_TYPES.has(type)) { setDirection(node, "ltr"); // A pinned table is not an `auto` ancestor, so its cells are free to // pick their own direction while the column order stays put. - node.children?.forEach((child) => visit(child, false)); + node.children?.forEach((child) => visit(child, false, false)); return; } @@ -792,7 +798,10 @@ function remarkTextDirection() { if (!insideAutoBlock) { setDirection(node, resolvedTextDirection(directionDetectionText(node))); } - node.children?.forEach((child) => visit(child, insideAutoBlock)); + // A nested list's items resolve from their own text just like a + // top-level list's, unless they sit under a pinned `dir="rtl"` + // ancestor — there, inheritance is the point, so leave them be. + node.children?.forEach((child) => visit(child, pinnedRtl && insideAutoBlock, pinnedRtl)); return; } @@ -815,6 +824,7 @@ function remarkTextDirection() { }; } const isDirectionBlock = !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type); + let pinnedRtlHere = false; if (isDirectionBlock && !insideAutoBlock) { // `dir="auto"` (and the `plaintext` CSS) is the browser's own first-strong // scan, which cannot discount a leading Latin tech token — "server.py זה @@ -822,13 +832,13 @@ function remarkTextDirection() { // pin the block with an explicit `dir="rtl"` (index.css lifts `plaintext` // for it); everywhere else the browser keeps resolving the block itself. const detectionText = directionDetectionText(node); - setDirection( - node, + const dir = firstStrongDirection(detectionText) === "ltr" && - resolvedTextDirection(detectionText) === "rtl" + resolvedTextDirection(detectionText) === "rtl" ? "rtl" - : "auto", - ); + : "auto"; + setDirection(node, dir); + pinnedRtlHere = dir === "rtl"; } else if (isDirectionBlock && insideAutoBlock) { // Inside a claimed block the `plaintext` CSS still re-resolves each // leaf from its own text — pin just the leaves whose leading Latin @@ -839,14 +849,19 @@ function remarkTextDirection() { resolvedTextDirection(detectionText) === "rtl" ) { setDirection(node, "rtl"); + pinnedRtlHere = true; } } node.children?.forEach((child) => - visit(child, insideAutoBlock || (isDirectionBlock && !insideAutoBlock)), + visit( + child, + insideAutoBlock || (isDirectionBlock && !insideAutoBlock), + pinnedRtl || pinnedRtlHere, + ), ); }; - visit(tree, false); + visit(tree, false, false); }; } From f213c8365a1af3015b1b6417a56b3f5cae126524 Mon Sep 17 00:00:00 2001 From: Amit
Date: Tue, 15 Sep 2026 13:40:49 +0300 Subject: [PATCH 30/31] test: update assertions for writingDirection/dir attributes after merge Three upstream tests (added after rtl diverged, unaware of this branch's RTL work) asserted exact output that predates the dir="auto" attribute and writingDirection run field this branch adds. Updated their expectations to match current, correct behavior rather than weakening the RTL feature. Co-Authored-By: Claude Sonnet 5 --- apps/mobile/src/lib/nativeMarkdownText.test.ts | 9 +++++---- apps/web/src/components/ChatMarkdown.test.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index dfffa41e9758..3956bc9b07e4 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -273,15 +273,16 @@ describe("nativeMarkdownDocumentRuns", () => { ], }); expect(runs).toEqual([ - { text: "Inspect ", role: "body" }, + { text: "Inspect ", role: "body", writingDirection: "ltr" }, { text: "Checkout.tsx", role: "body", href: "src/Checkout.tsx", fileIcon: "react", sourceText: "@src/Checkout.tsx", + writingDirection: "ltr", }, - { text: ". Use @t3tools/contracts.", role: "body" }, + { text: ". Use @t3tools/contracts.", role: "body", writingDirection: "ltr" }, ]); }); @@ -599,8 +600,8 @@ describe("nativeMarkdownDocumentRuns", () => { // Merging these would render one chip and emit one copy range with a // combined label for two distinct references. expect(runs).toEqual([ - { text: "First", role: "body", href, fileIcon: "bash" }, - { text: "Second", role: "body", href, fileIcon: "bash" }, + { text: "First", role: "body", href, fileIcon: "bash", writingDirection: "ltr" }, + { text: "Second", role: "body", href, fileIcon: "bash", writingDirection: "ltr" }, ]); }); }); diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 46e04ac1a61c..15f3e9c9d4b1 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -708,15 +708,15 @@ describe("ChatMarkdown heading levels", () => { />, ); - expect(html).toContain(' Top
'); - expect(html).toContain('Section
'); - expect(html).toContain('Fine print
'); + expect(html).toContain('Top
'); + expect(html).toContain('Section
'); + expect(html).toContain('Fine print
'); }); it("leaves heading levels alone when the markdown is not nested", () => { const html = renderToStaticMarkup(); - expect(html).toContain(" Top
"); + expect(html).toContain('Top
'); }); }); From 180ad6f161912330d82ae2e99743a34ac271727d Mon Sep 17 00:00:00 2001 From: AmitDate: Tue, 15 Sep 2026 14:01:08 +0300 Subject: [PATCH 31/31] fix: address CodeRabbit review findings on PR #11868 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mobile: forward inherited direction to plain paragraphs in NativeMarkdownBlock so they don't re-resolve their own direction independently of an ancestor list/quote - mobile: strip bidi isolate marks from canonicalSelection's context-aware copy path in T3MarkdownTextSelectionModule.kt, mirroring the sibling copyTextWithoutInlineImages path (unverified: no Android SDK/gradle in this environment to compile-check, low-risk mechanical change) - mobile: stop isolateLatinRuns from splitting inside any $-prefixed skill-token span ($ui, $2spec, ...) instead of only guarding the character immediately after `$` — the prior lookbehind fix was incomplete for any skill name longer than one character - mobile: keep the composer's computed writingDirection/textAlign last in the style array so a caller-supplied textStyle can't override the draft's own direction - web: tighten a vacuous test assertion (renderToStaticMarkup emits no newline between adjacent tags, so the old assertion could never fail) Dismissed: raw-HTML paragraphs/headings/table cells (via rehypeRaw) verified real but left unfixed — a correct fix means duplicating remarkTextDirection's mdast-based logic as a second hast-based pass, which risks behavior drift in the already-extensively-tested markdown- origin direction path for what is a narrow edge case (literal block- level HTML in chat prose). Left for a follow-up. Co-Authored-By: Claude Sonnet 5 --- .../T3MarkdownTextSelectionModule.kt | 7 +++- .../src/NativeMarkdownBlock.tsx | 1 + .../src/nativeMarkdownText.ts | 25 ++++++++++++-- .../mobile/src/lib/nativeMarkdownText.test.ts | 34 +++++++++++++++++++ apps/mobile/src/native/T3ComposerEditor.tsx | 6 +++- apps/web/src/components/ChatMarkdown.test.tsx | 6 +++- 6 files changed, 73 insertions(+), 6 deletions(-) diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt index dc16ebcfaea1..1e43ab5e7da3 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3MarkdownTextSelectionModule.kt @@ -93,7 +93,12 @@ private fun canonicalSelection( hasContext = true } } - return if (hasContext) canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") else null + if (!hasContext) return null + var sanitized = canonical.toString().replace(OBJECT_REPLACEMENT_CHARACTER, "") + for (isolate in BIDI_ISOLATE_CHARACTERS) { + sanitized = sanitized.replace(isolate.toString(), "") + } + return sanitized } private fun selectedContextRecords(records: JSONArray, selectedText: String): JSONArray { diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx index 76367110a268..997ce6abffdf 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx @@ -623,6 +623,7 @@ export function NativeMarkdownBlock(props: { skills={props.skills} textStyle={props.textStyle} onLinkPress={props.onLinkPress} + direction={props.direction} /> ); case "html_block": diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index 8034ddca3ec2..b10260c6e53c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -539,11 +539,30 @@ function nodeTextContent(node: MarkdownNode): string { // thin neutrals ("speed-to-lead", "U1+U2+U3+U5", "OpenAI export"); a connector // is only swallowed when another Latin word follows it, so sentence-final // punctuation stays outside the isolate. Mirrors the web app's pass. -const LATIN_RUN = - /(? `\u2066${run}\u2069`); + let result = ""; + let cursor = 0; + for (const match of text.matchAll(SKILL_TOKEN_SPAN)) { + const start = match.index ?? 0; + const end = start + match[0].length; + result += text.slice(cursor, start).replace(LATIN_RUN, (run) => `\u2066${run}\u2069`); + result += match[0]; + cursor = end; + } + result += text.slice(cursor).replace(LATIN_RUN, (run) => `\u2066${run}\u2069`); + return result; } function appendNode( diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 3956bc9b07e4..d9bfda7a0e41 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -356,6 +356,40 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("keeps a skill reference intact when Latin runs are isolated for RTL text", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }], "rtl"); + const skillRun = runs.find((run) => run.skillName === "ui"); + expect(skillRun?.text).toBe("$ui"); + expect(skillRun?.skillLabel).toBe("UI"); + }); + + it("keeps a digit-led skill reference intact when Latin runs are isolated for RTL text", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $2spec for this." }], + }, + ], + }; + + const runs = nativeMarkdownDocumentRuns(node, [{ name: "2spec", displayName: "2Spec" }], "rtl"); + const skillRun = runs.find((run) => run.skillName === "2spec"); + expect(skillRun?.text).toBe("$2spec"); + expect(skillRun?.skillLabel).toBe("2Spec"); + }); + it("decorates known skill references inside blockquotes", () => { const node: MarkdownNode = { type: "blockquote", diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 587e817aca6d..167e807da1fb 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -60,10 +60,14 @@ export function ComposerEditor({ fontFamily, ...bodyText, paddingVertical: contentInsetVertical, + }, + textStyle, + // Direction is the draft's, not the caller's — kept last so it can't + // be overridden by a `textAlign`/`writingDirection` in `textStyle`. + { textAlign: writingDirection === "rtl" ? "right" : "left", writingDirection, }, - textStyle, ]} /> diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 15f3e9c9d4b1..6621ad06bf64 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -899,7 +899,11 @@ describe("chat markdown text direction", () => { it("does not re-mark the blocks inside a claimed quote", () => { const html = render("> اقتباس"); - expect(html).not.toContain(' \n'); + // `renderToStaticMarkup` serializes adjacent tags with no separator, so + // this is the actual boundary a nested, wrongly re-marked paragraph + // would produce — the newline-separated form the assertion used to check + // for can never appear in real output. + expect(html).not.toContain('
'); }); it("pins code left-to-right so an Arabic comment cannot reorder a snippet", () => {