From ae7e377747f7cc58fc36a14b4918ba1d67b1ad62 Mon Sep 17 00:00:00 2001
From: dirbalak <30323349+dirbalak@users.noreply.github.com>
Date: Fri, 13 Feb 2026 06:15:20 +0200
Subject: [PATCH] feat(ui): add RTL support for Hebrew/Arabic text in webchat
(openclaw#11498) thanks @dirbalak
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test
Co-authored-by: dirbalak <30323349+dirbalak@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
---
CHANGELOG.md | 1 +
ui/src/styles/chat/text.css | 20 ++++++++++++++++++++
ui/src/ui/chat/grouped-render.ts | 3 ++-
ui/src/ui/text-direction.test.ts | 24 ++++++++++++++++++++++++
ui/src/ui/text-direction.ts | 30 ++++++++++++++++++++++++++++++
ui/src/ui/views/chat.ts | 2 ++
6 files changed, 79 insertions(+), 1 deletion(-)
create mode 100644 ui/src/ui/text-direction.test.ts
create mode 100644 ui/src/ui/text-direction.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a354580de1..a6330ba8479 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -199,6 +199,7 @@ Docs: https://docs.openclaw.ai
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
+- Web UI: add RTL auto-direction support for Hebrew/Arabic text in chat composer and rendered messages. (#11498) Thanks @dirbalak.
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css
index 13e245de251..d6eea9866b2 100644
--- a/ui/src/styles/chat/text.css
+++ b/ui/src/styles/chat/text.css
@@ -122,3 +122,23 @@
border-top: 1px solid var(--border);
margin: 1em 0;
}
+
+/* =============================================
+ RTL (Right-to-Left) SUPPORT
+ ============================================= */
+
+.chat-text[dir="rtl"] {
+ text-align: right;
+}
+
+.chat-text[dir="rtl"] :where(ul, ol) {
+ padding-left: 0;
+ padding-right: 1.5em;
+}
+
+.chat-text[dir="rtl"] :where(blockquote) {
+ border-left: none;
+ border-right: 3px solid var(--border);
+ padding-left: 0;
+ padding-right: 1em;
+}
diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts
index 545b3df7c50..63da6b982b1 100644
--- a/ui/src/ui/chat/grouped-render.ts
+++ b/ui/src/ui/chat/grouped-render.ts
@@ -3,6 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { AssistantIdentity } from "../assistant-identity.ts";
import type { MessageGroup } from "../types/chat-types.ts";
import { toSanitizedMarkdownHtml } from "../markdown.ts";
+import { detectTextDirection } from "../text-direction.ts";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown.ts";
import {
extractTextCached,
@@ -272,7 +273,7 @@ function renderGroupedMessage(
}
${
markdown
- ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
`
+ ? html`${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
`
: nothing
}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}
diff --git a/ui/src/ui/text-direction.test.ts b/ui/src/ui/text-direction.test.ts
new file mode 100644
index 00000000000..ed9d22d8506
--- /dev/null
+++ b/ui/src/ui/text-direction.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { detectTextDirection } from "./text-direction.ts";
+
+describe("detectTextDirection", () => {
+ it("returns ltr for null and empty input", () => {
+ expect(detectTextDirection(null)).toBe("ltr");
+ expect(detectTextDirection("")).toBe("ltr");
+ });
+
+ it("detects rtl when first significant char is rtl script", () => {
+ expect(detectTextDirection("שלום עולם")).toBe("rtl");
+ expect(detectTextDirection("مرحبا")).toBe("rtl");
+ });
+
+ it("detects ltr when first significant char is ltr", () => {
+ expect(detectTextDirection("Hello world")).toBe("ltr");
+ });
+
+ it("skips punctuation and markdown prefix characters before detection", () => {
+ expect(detectTextDirection("**שלום")).toBe("rtl");
+ expect(detectTextDirection("# مرحبا")).toBe("rtl");
+ expect(detectTextDirection("- hello")).toBe("ltr");
+ });
+});
diff --git a/ui/src/ui/text-direction.ts b/ui/src/ui/text-direction.ts
new file mode 100644
index 00000000000..8af675f7ec8
--- /dev/null
+++ b/ui/src/ui/text-direction.ts
@@ -0,0 +1,30 @@
+/**
+ * RTL (Right-to-Left) text direction detection.
+ * Detects Hebrew, Arabic, Syriac, Thaana, Nko, Samaritan, Mandaic, Adlam,
+ * Phoenician, and Lydian scripts using Unicode Script Properties.
+ */
+
+const RTL_CHAR_REGEX =
+ /\p{Script=Hebrew}|\p{Script=Arabic}|\p{Script=Syriac}|\p{Script=Thaana}|\p{Script=Nko}|\p{Script=Samaritan}|\p{Script=Mandaic}|\p{Script=Adlam}|\p{Script=Phoenician}|\p{Script=Lydian}/u;
+
+/**
+ * Detect text direction from the first significant character.
+ * @param text - The text to check
+ * @param skipPattern - Characters to skip when looking for the first significant char.
+ * Defaults to whitespace and Unicode punctuation/symbols.
+ */
+export function detectTextDirection(
+ text: string | null,
+ skipPattern: RegExp = /[\s\p{P}\p{S}]/u,
+): "rtl" | "ltr" {
+ if (!text) {
+ return "ltr";
+ }
+ for (const char of text) {
+ if (skipPattern.test(char)) {
+ continue;
+ }
+ return RTL_CHAR_REGEX.test(char) ? "rtl" : "ltr";
+ }
+ return "ltr";
+}
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index ce51fde5ff8..a74b6cf166b 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -11,6 +11,7 @@ import {
} from "../chat/grouped-render.ts";
import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts";
import { icons } from "../icons.ts";
+import { detectTextDirection } from "../text-direction.ts";
import { renderMarkdownSidebar } from "./markdown-sidebar.ts";
import "../components/resizable-divider.ts";
@@ -375,6 +376,7 @@ export function renderChat(props: ChatProps) {