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) {