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>
This commit is contained in:
dirbalak
2026-02-13 06:15:20 +02:00
committed by GitHub
parent c6ecd2a044
commit ae7e377747
6 changed files with 79 additions and 1 deletions

View File

@@ -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.

View File

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

View File

@@ -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`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing
}
${toolCards.map((card) => renderToolCardSidebar(card, onOpenSidebar))}

View File

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

View File

@@ -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";
}

View File

@@ -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) {
<textarea
${ref((el) => el && adjustTextareaHeight(el as HTMLTextAreaElement))}
.value=${props.draft}
dir=${detectTextDirection(props.draft)}
?disabled=${!props.connected}
@keydown=${(e: KeyboardEvent) => {
if (e.key !== "Enter") {