Skip to content

Commit 3143842

Browse files
committed
fix(session-ui): keep every script and prompt line in its own direction
Counting only Latin, Greek and Cyrillic as left to right left CJK, Devanagari, Thai and Korean text neutral, so a single Arabic or Hebrew word flipped a Chinese or Hindi paragraph to right to left. Count every letter and let its script decide instead. The user bubble also lost its per-line direction when `unicode-bidi: plaintext` was removed: one `dir` for the whole prompt laid an English line out as right to left when the rest was Arabic. Render each line in its own isolated span with a `dir` computed from that line, and keep the message direction on the bubble for alignment.
1 parent 8d524d8 commit 3143842

7 files changed

Lines changed: 117 additions & 90 deletions

File tree

‎packages/session-ui/component-tests/markdown.spec.ts‎

Lines changed: 16 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ story("derives each block direction from the language it is mostly written in",
7979
"<p>سأستخدم React في المشروع</p>",
8080
"<p>React هو مكتبة واجهة برمجية.</p>",
8181
"<p>Use React in the project.</p>",
82+
"<p>这是一个测试 مرحبا</p>",
83+
"<p>यह एक परीक्षण है مرحبا</p>",
8284
"<p>سأستخدم <code>npm install</code> هنا</p>",
8385
"<ul><li>سأستخدم npm install في المشروع</li><li>Use the CLI</li></ul>",
8486
"<table><thead><tr><th>الاسم</th><th>القيمة</th></tr></thead></table>",
@@ -90,6 +92,8 @@ story("derives each block direction from the language it is mostly written in",
9092
'<p dir="rtl">سأستخدم React في المشروع</p>',
9193
'<p dir="rtl">React هو مكتبة واجهة برمجية.</p>',
9294
'<p dir="ltr">Use React in the project.</p>',
95+
'<p dir="ltr">这是一个测试 مرحبا</p>',
96+
'<p dir="ltr">यह एक परीक्षण है مرحبا</p>',
9397
'<p dir="rtl">سأستخدم <code>npm install</code> هنا</p>',
9498
'<ul dir="rtl"><li dir="rtl">سأستخدم npm install في المشروع</li><li dir="ltr">Use the CLI</li></ul>',
9599
'<table dir="rtl"><thead><tr><th dir="rtl">الاسم</th><th dir="rtl">القيمة</th></tr></thead></table>',
@@ -124,43 +128,20 @@ for (const streaming of [false, true]) {
124128
const paragraphs = markdown.locator("p")
125129
await expect(paragraphs).toHaveCount(3)
126130

127-
// The first logical word of a right-to-left paragraph sits right of the last one.
128-
const layout = await paragraphs.evaluateAll((elements) =>
129-
elements.map((element) => {
130-
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
131-
const words: { left: number; top: number }[] = []
132-
133-
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
134-
const text = node.nodeValue ?? ""
135-
let offset = 0
136-
137-
for (const part of text.split(/(\s+)/)) {
138-
if (part.trim()) {
139-
const range = document.createRange()
140-
range.setStart(node, offset)
141-
range.setEnd(node, offset + part.length)
142-
const box = range.getBoundingClientRect()
143-
words.push({ left: box.left, top: box.top })
144-
}
145-
146-
offset += part.length
147-
}
148-
}
149-
150-
const firstLine = words.filter((word) => Math.abs(word.top - words[0].top) < 10)
151-
152-
return {
153-
dir: element.getAttribute("dir"),
154-
direction: getComputedStyle(element).direction,
155-
firstOnRight: firstLine[0].left > firstLine[firstLine.length - 1].left,
156-
}
157-
}),
131+
// The direction each block computes decides how the bidi algorithm
132+
// orders the words inside it.
133+
const blocks = await markdown.locator("h2, p").evaluateAll((elements) =>
134+
elements.map((element) => ({
135+
dir: element.getAttribute("dir"),
136+
direction: getComputedStyle(element).direction,
137+
})),
158138
)
159139

160-
expect(layout).toEqual([
161-
{ dir: "rtl", direction: "rtl", firstOnRight: true },
162-
{ dir: "rtl", direction: "rtl", firstOnRight: true },
163-
{ dir: "ltr", direction: "ltr", firstOnRight: false },
140+
expect(blocks).toEqual([
141+
{ dir: "ltr", direction: "ltr" },
142+
{ dir: "rtl", direction: "rtl" },
143+
{ dir: "rtl", direction: "rtl" },
144+
{ dir: "ltr", direction: "ltr" },
164145
])
165146
},
166147
)

‎packages/session-ui/component-tests/session-timeline.spec.ts‎

Lines changed: 22 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -129,38 +129,27 @@ story("keeps patch file disclosures independent", async ({ mount }) => {
129129
await expect(deleted).toHaveAttribute("aria-expanded", "false")
130130
})
131131

132-
story("keeps an Arabic prompt right-to-left when it starts with a Latin word", async ({ mount }) => {
132+
story("keeps each prompt line in the direction it is mostly written in", async ({ mount }) => {
133133
const root = await mount("current-session-timeline-rows--arabic-prompt")
134-
const text = root.locator('[data-slot="user-message-text"]')
135-
await expect(text).toHaveAttribute("dir", "rtl")
136-
expect(await text.evaluate((element) => getComputedStyle(element).direction)).toBe("rtl")
137-
138-
// The first logical word of a right-to-left prompt sits right of the last one.
139-
const order = await text.evaluate((element) => {
140-
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
141-
const words: { left: number; top: number }[] = []
142-
143-
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
144-
const value = node.nodeValue ?? ""
145-
let offset = 0
146-
147-
for (const part of value.split(/(\s+)/)) {
148-
if (part.trim()) {
149-
const range = document.createRange()
150-
range.setStart(node, offset)
151-
range.setEnd(node, offset + part.length)
152-
const box = range.getBoundingClientRect()
153-
words.push({ left: box.left, top: box.top })
154-
}
155-
156-
offset += part.length
157-
}
158-
}
159-
160-
const firstLine = words.filter((word) => Math.abs(word.top - words[0].top) < 10)
161-
162-
return { first: firstLine[0].left, last: firstLine[firstLine.length - 1].left }
163-
})
164-
165-
expect(order.first).toBeGreaterThan(order.last)
134+
const bubbles = root.locator('[data-slot="user-message-text"]')
135+
await expect(bubbles).toHaveCount(2)
136+
137+
// The prompt opens with "React", so the first strong character is Latin while
138+
// the sentence is mostly Arabic.
139+
await expect(bubbles.first()).toHaveAttribute("dir", "rtl")
140+
expect(await bubbles.first().evaluate((element) => getComputedStyle(element).direction)).toBe("rtl")
141+
142+
const lines = bubbles.nth(1).locator('[data-slot="user-message-line"]')
143+
await expect(lines).toHaveCount(2)
144+
expect(
145+
await lines.evaluateAll((elements) =>
146+
elements.map((element) => ({
147+
dir: element.getAttribute("dir"),
148+
direction: getComputedStyle(element).direction,
149+
})),
150+
),
151+
).toEqual([
152+
{ dir: "ltr", direction: "ltr" },
153+
{ dir: "rtl", direction: "rtl" },
154+
])
166155
})

‎packages/session-ui/src/components/message-part.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@
184184
transition: none;
185185
}
186186

187+
[data-slot="user-message-line"] {
188+
unicode-bidi: isolate;
189+
}
190+
187191
[data-highlight="file"],
188192
[data-highlight="agent"] {
189193
color: inherit;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { textDirection } from "./text-direction"
3+
4+
describe("textDirection", () => {
5+
test("follows the language most of the text is written in", () => {
6+
expect(textDirection("React هو مكتبة واجهة برمجية")).toBe("rtl")
7+
expect(textDirection("سأستخدم React في المشروع")).toBe("rtl")
8+
expect(textDirection("Use React in the project")).toBe("ltr")
9+
})
10+
11+
test("keeps left-to-right scripts outside the rtl set", () => {
12+
expect(textDirection("这是一个测试 مرحبا")).toBe("ltr")
13+
expect(textDirection("これはテストです مرحبا")).toBe("ltr")
14+
expect(textDirection("यह एक परीक्षण है مرحبا")).toBe("ltr")
15+
expect(textDirection("한국어 테스트 مرحبا")).toBe("ltr")
16+
})
17+
18+
test("treats digits and punctuation as neutral", () => {
19+
expect(textDirection("1234 (5%) -- 6.")).toBeUndefined()
20+
expect(textDirection("مرحبا 1234")).toBe("rtl")
21+
})
22+
23+
test("leaves a tie to the ambient direction", () => {
24+
expect(textDirection("مرحبا hello")).toBeUndefined()
25+
expect(textDirection("")).toBeUndefined()
26+
})
27+
})

‎packages/session-ui/src/components/text-direction.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1-
// Right-to-left scripts: Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic.
2-
const rightToLeftScript = /[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufeff]/g
1+
// Letters decide direction; digits, punctuation and whitespace are neutral.
2+
const letter = /\p{L}/gu
33

4-
// Left-to-right scripts that carry letters: Latin, Greek, Cyrillic.
5-
const leftToRightScript = /[a-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02af\u0370-\u03ff\u0400-\u04ff\u1e00-\u1eff]/gi
4+
// Scripts written right to left. A letter in any other script reads left to right.
5+
const rightToLeft = /(?=[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Adlam}])\p{L}/gu
66

77
/**
88
* `dir="auto"` resolves from the first strong character, which flips a mostly
9-
* Arabic text that opens with an identifier such as "React". Counting both
9+
* Arabic text that opens with an identifier such as "React". Counting the
1010
* scripts and keeping the majority holds the text in the language it is mostly
11-
* written in. Digits and punctuation are neutral and decide nothing.
11+
* written in. Counting every letter rather than a list of left-to-right scripts
12+
* keeps Chinese, Japanese, Korean, Devanagari and other left-to-right writing
13+
* systems left to right when a single right-to-left word appears.
1214
*/
1315
export function textDirection(text: string) {
14-
const rtl = text.length - text.replace(rightToLeftScript, "").length
15-
const ltr = text.length - text.replace(leftToRightScript, "").length
16+
const letters = text.match(letter)?.length ?? 0
17+
const rtl = text.match(rightToLeft)?.length ?? 0
18+
const ltr = letters - rtl
1619

1720
if (rtl === ltr) return
1821

‎packages/session-ui/src/message/message-content.tsx‎

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,9 @@ function CurrentHighlightedText(props: {
528528
files: PromptFileAttachment[]
529529
agents: PromptAgentAttachment[]
530530
}) {
531-
const segments = createMemo(() => {
531+
// A prompt can switch language line by line, so each line resolves its own
532+
// direction instead of inheriting one answer for the whole message.
533+
const lines = createMemo(() => {
532534
const references = [
533535
...props.files.flatMap((file) =>
534536
file.mention ? [{ start: file.mention.start, end: file.mention.end, type: "file" as const }] : [],
@@ -538,30 +540,50 @@ function CurrentHighlightedText(props: {
538540
),
539541
].sort((a, b) => a.start - b.start)
540542

541-
const result: HighlightSegment[] = []
543+
const segments: HighlightSegment[] = []
542544
let last = 0
543545
references.forEach((reference) => {
544546
if (reference.start < last) return
545547

546-
if (reference.start > last) result.push({ text: props.text.slice(last, reference.start) })
547-
result.push({ text: props.text.slice(reference.start, reference.end), type: reference.type })
548+
if (reference.start > last) segments.push({ text: props.text.slice(last, reference.start) })
549+
segments.push({ text: props.text.slice(reference.start, reference.end), type: reference.type })
548550
last = reference.end
549551
})
550552

551-
if (last < props.text.length) result.push({ text: props.text.slice(last) })
553+
if (last < props.text.length) segments.push({ text: props.text.slice(last) })
552554

553-
return result
555+
return segments.reduce<HighlightSegment[][]>(
556+
(lines, segment) => {
557+
segment.text.split("\n").forEach((text, index) => {
558+
if (index > 0) lines.push([])
559+
560+
if (text) lines[lines.length - 1].push({ text, type: segment.type })
561+
})
562+
563+
return lines
564+
},
565+
[[]],
566+
)
554567
})
555568

556569
return (
557-
<For each={segments()}>
558-
{(segment) => (
559-
<span data-highlight={segment.type}>
560-
<Show when={segment.type && segment.text.startsWith("@")} fallback={segment.text}>
561-
<span data-slot="user-message-mention-prefix">@</span>
562-
{segment.text.slice(1)}
563-
</Show>
564-
</span>
570+
<For each={lines()}>
571+
{(line, index) => (
572+
<>
573+
{index() > 0 ? "\n" : ""}
574+
<span data-slot="user-message-line" dir={textDirection(line.map((segment) => segment.text).join(""))}>
575+
<For each={line}>
576+
{(segment) => (
577+
<span data-highlight={segment.type}>
578+
<Show when={segment.type && segment.text.startsWith("@")} fallback={segment.text}>
579+
<span data-slot="user-message-mention-prefix">@</span>
580+
{segment.text.slice(1)}
581+
</Show>
582+
</span>
583+
)}
584+
</For>
585+
</span>
586+
</>
565587
)}
566588
</For>
567589
)

‎packages/session-ui/src/storybook/current-session-fixtures.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,6 +1112,7 @@ export const arabicPromptDocument = document([
11121112
completed: 92_500,
11131113
content: [{ type: "text", text: "تمام. سأشرح الخطوات بالترتيب." }],
11141114
}),
1115+
user("msg_user_multiline", "Fix the login bug please\nمرحبا كيف حالك؟ سأراجع الكود الآن.", 93_000),
11151116
])
11161117

11171118
export const attachmentsAndCommentsPresentation = {

0 commit comments

Comments
 (0)