Skip to content

Commit c2dc2ea

Browse files
Dananzmaria-rcks
andauthored
fix(web,mobile): Hebrew and Arabic read right to left in questions, citations, attachments and diff comments (#24050)
Co-authored-by: maria-rcks <[email protected]>
1 parent c3303fb commit c2dc2ea

16 files changed

Lines changed: 292 additions & 50 deletions

‎apps/mobile/src/features/threads/PendingUserInputCard.tsx‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { Button } from "../../components/ui/Button";
22
import { QuestionAttachments } from "./QuestionAttachments";
33
import type { EnvironmentId, RuntimeRequestId } from "@t3tools/contracts";
44
import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests";
5+
import {
6+
markdownProseDirection,
7+
textDirection,
8+
type TextDirection,
9+
} from "@t3tools/shared/textDirection";
510
import { useRef } from "react";
611
import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native";
712
import Animated, {
@@ -61,14 +66,31 @@ export interface PendingUserInputCardProps {
6166
/** Android cannot hit-test overlays beyond their parent's bounds; render its expanded card in-flow. */
6267
const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios";
6368

64-
function QuestionText(props: { readonly markdown: string; readonly environmentId: EnvironmentId }) {
69+
function proseDirectionStyle(text: string, setDirection: TextDirection | undefined) {
70+
const direction = textDirection(text);
71+
return {
72+
...(direction ? { direction, writingDirection: direction } : null),
73+
textAlign: (direction === "rtl") === (setDirection === "rtl") ? "left" : "right",
74+
} as const;
75+
}
76+
77+
function QuestionText(props: {
78+
readonly markdown: string;
79+
readonly environmentId: EnvironmentId;
80+
readonly setDirection: TextDirection | undefined;
81+
}) {
6582
const styles = useMarkdownPreviewStyles();
6683
const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId);
6784
const openLink = useOpenPullRequest();
6885
return (
6986
<SelectableMarkdownText
7087
markdown={markdown}
7188
textStyle={styles.nativeTextStyle}
89+
writingDirection={markdownProseDirection(markdown)}
90+
// Android's renderer aligns relative to each paragraph's direction.
91+
textAlign={
92+
Platform.OS === "android" ? undefined : props.setDirection === "rtl" ? "right" : "left"
93+
}
7294
onLinkPress={(href) => void openLink(href)}
7395
/>
7496
);
@@ -79,6 +101,15 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
79101
const canRespond = props.pendingUserInput.responseCapability !== "not_resumable";
80102
const isResponding = props.respondingUserInputId === props.pendingUserInput.requestId;
81103
const responseDisabled = !canRespond || isResponding;
104+
const setDirection = markdownProseDirection(
105+
props.pendingUserInput.questions
106+
.flatMap((question) => [
107+
question.header,
108+
question.question,
109+
...question.options.flatMap((option) => [option.label, option.description ?? ""]),
110+
])
111+
.join(" "),
112+
);
82113

83114
const cardCoverage = props.cardCoverage;
84115
const barHeightRef = useRef(0);
@@ -217,10 +248,17 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
217248
const draft = props.drafts[question.id];
218249
return (
219250
<View key={question.id} className="gap-2 pt-1">
220-
<Text className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted">
251+
<Text
252+
className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted"
253+
style={proseDirectionStyle(question.header, setDirection)}
254+
>
221255
{question.header}
222256
</Text>
223-
<QuestionText markdown={question.question} environmentId={props.environmentId} />
257+
<QuestionText
258+
markdown={question.question}
259+
environmentId={props.environmentId}
260+
setDirection={setDirection}
261+
/>
224262
<View className="gap-2">
225263
{question.options.map((option) => {
226264
const optionValue = option.value ?? option.label.trim();
@@ -251,11 +289,15 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
251289
"font-t3-bold text-sm",
252290
selected ? "text-foreground" : "text-foreground-secondary",
253291
)}
292+
style={proseDirectionStyle(option.label, setDirection)}
254293
>
255294
{option.label}
256295
</Text>
257296
{description ? (
258-
<Text className="font-sans text-sm leading-5 text-foreground-muted">
297+
<Text
298+
className="font-sans text-sm leading-5 text-foreground-muted"
299+
style={proseDirectionStyle(description, setDirection)}
300+
>
259301
{description}
260302
</Text>
261303
) : null}

‎apps/mobile/src/native/EnrichedMarkdownText.tsx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -264,13 +264,16 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
264264
};
265265
}, [props.contextClipboardFragment, props.markdown]);
266266
const { fontScale } = useWindowDimensions();
267-
const markdownStyle = useMemo(
268-
() => ({
269-
...enrichedStyle(props.textStyle, themeAppearance === "dark", fontScale),
267+
const markdownStyle = useMemo(() => {
268+
const style = enrichedStyle(props.textStyle, themeAppearance === "dark", fontScale);
269+
return {
270+
...style,
271+
paragraph: props.textAlign
272+
? { ...style.paragraph, textAlign: props.textAlign }
273+
: style.paragraph,
270274
linkVariants,
271-
}),
272-
[props.textStyle, themeAppearance, fontScale, linkVariants],
273-
);
275+
};
276+
}, [props.textStyle, props.textAlign, themeAppearance, fontScale, linkVariants]);
274277
const renderers = renderImage
275278
? {
276279
image: (block: MarkdownImageBlock) => {
@@ -312,6 +315,7 @@ export function MobileEnrichedMarkdownText(props: SelectableMarkdownTextProps) {
312315
<EnrichedMarkdownText
313316
markdown={markdown}
314317
markdownStyle={markdownStyle}
318+
writingDirection={props.writingDirection}
315319
onLinksChange={onLinksChange}
316320
renderers={renderers}
317321
resolveImageSource={resolveNativeImageSource}

‎apps/mobile/src/native/SelectableMarkdownText.types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,6 @@ export interface SelectableMarkdownTextProps {
7878
readonly onImagePress?: (href: string) => void;
7979
readonly marginTop?: number;
8080
readonly marginBottom?: number;
81+
readonly writingDirection?: "ltr" | "rtl";
82+
readonly textAlign?: "left" | "right";
8183
}

‎apps/web/src/components/chat/AssistantCitationChip.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCom
3131
import { observeAssistantCitationCommentSource } from "./AssistantCitationSource";
3232
import { composerFloatingLayerProps } from "./composerEventScope";
3333
import { observeResize } from "~/lib/observeResize";
34+
import { textDirection } from "~/textDirection";
3435

3536
export function AssistantCitationChip({
3637
citation,
@@ -130,7 +131,9 @@ export function AssistantCitationChip({
130131
aria-label={`View cited assistant text: ${label}`}
131132
>
132133
<QuoteIcon aria-hidden="true" />
133-
<ContextChipLabel className="max-w-[16em]">{label}</ContextChipLabel>
134+
<ContextChipLabel className="max-w-[16em]" dir={textDirection(label)}>
135+
{label}
136+
</ContextChipLabel>
134137
</Link>
135138
);
136139
if (!composer) {
@@ -268,10 +271,13 @@ function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) {
268271
getVirtualizedScrollFadeClassName(fade),
269272
)}
270273
>
271-
<blockquote className="border-l-2 border-border pl-3 text-muted-foreground">
274+
<blockquote
275+
className="border-l-2 border-border pl-3 text-muted-foreground"
276+
dir={textDirection(citation.text)}
277+
>
272278
{citation.text}
273279
</blockquote>
274-
{citation.comment ? <p>{citation.comment}</p> : null}
280+
{citation.comment ? <p dir={textDirection(citation.comment)}>{citation.comment}</p> : null}
275281
</div>
276282
);
277283
}

‎apps/web/src/components/chat/AssistantCitationCommentEditor.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ASSISTANT_CITATION_MAX_COMMENT_LENGTH, type AssistantCitation } from "@t3tools/contracts";
22
import { useState, type Ref } from "react";
3+
import { textDirection } from "~/textDirection";
34

45
import { Button } from "../ui/button";
56

@@ -51,6 +52,7 @@ export function AssistantCitationCommentEditor({
5152
aria-invalid={commentTooLong || undefined}
5253
placeholder="Add an optional comment..."
5354
rows={2}
55+
dir={textDirection(comment)}
5456
className="field-sizing-content block max-h-40 min-h-16 w-full resize-none bg-transparent px-1 py-1.5 text-base outline-none placeholder:text-muted-foreground sm:text-sm"
5557
value={comment}
5658
onChange={(event) => {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
2+
import { expect, it, vi } from "vite-plus/test";
3+
4+
vi.mock(import("../ChatMarkdown"), () => ({
5+
default: (({ text }: { text: string }) => <p>{text}</p>) as never,
6+
}));
7+
8+
import { render } from "../../../test/render";
9+
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
10+
11+
type Question = {
12+
header: string;
13+
question: string;
14+
options: { label: string; description: string }[];
15+
};
16+
17+
const renderPanel = (questions: Question[], questionIndex = 0) =>
18+
render(
19+
<ComposerPendingUserInputPanel
20+
pendingUserInputs={[
21+
{
22+
requestId: RuntimeRequestId.make("question-direction"),
23+
createdAt: "2026-10-23T00:00:00.000Z",
24+
dismissible: false,
25+
responseCapability: "live",
26+
questions: questions.map((question, index) => ({
27+
...question,
28+
id: `question-${index}`,
29+
multiSelect: false,
30+
})),
31+
},
32+
]}
33+
cwd={undefined}
34+
threadRef={{ environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }}
35+
respondingRequestIds={[]}
36+
answers={{}}
37+
questionIndex={questionIndex}
38+
onToggleOption={() => {}}
39+
onAdvance={() => {}}
40+
onDismiss={() => {}}
41+
/>,
42+
);
43+
44+
it("reads a mostly Hebrew question set right to left", async () => {
45+
await using screen = await renderPanel([
46+
{
47+
header: "גישה",
48+
question: "איך לבצע את המיגרציה?",
49+
options: [
50+
{
51+
label: "React Server Components בכל המסכים של האפליקציה",
52+
description: "מסך אחד בכל פעם",
53+
},
54+
{ label: "Keep it on React 18", description: "English only" },
55+
],
56+
},
57+
]);
58+
59+
const text = (value: string) => screen.getByText(value, { exact: true });
60+
await expect.element(text("גישה")).toHaveStyle({ direction: "rtl" });
61+
await expect
62+
.element(text("React Server Components בכל המסכים של האפליקציה"))
63+
.toHaveStyle({ direction: "rtl" });
64+
await expect.element(text("מסך אחד בכל פעם")).toHaveStyle({ direction: "rtl" });
65+
await expect
66+
.element(screen.getByRole("button", { name: /React Server/ }))
67+
.toHaveStyle({ direction: "rtl" });
68+
await expect.element(text("Keep it on React 18")).toHaveStyle({ direction: "ltr" });
69+
70+
await screen.getByTitle("Hide the question and its options").click();
71+
await expect.element(text("איך לבצע את המיגרציה?")).toHaveStyle({ direction: "rtl" });
72+
});
73+
74+
it("keeps one direction for the whole set when a single question is Hebrew", async () => {
75+
await using screen = await renderPanel(
76+
[
77+
{
78+
header: "Migration",
79+
question: "How should we move to React Server Components?",
80+
options: [
81+
{ label: "Screen by screen", description: "Lower risk, each step tested alone." },
82+
{ label: "Server Actions for all forms first", description: "Then the rest." },
83+
],
84+
},
85+
{
86+
header: "גרסה",
87+
question: "בחר גרסה",
88+
options: [{ label: "גרסה 3 (חדשה!)", description: "!!!" }],
89+
},
90+
],
91+
1,
92+
);
93+
94+
await expect
95+
.element(screen.getByRole("button", { name: /גרסה 3/ }))
96+
.toHaveStyle({ direction: "ltr" });
97+
await expect
98+
.element(screen.getByText("גרסה 3 (חדשה!)", { exact: true }))
99+
.toHaveStyle({ direction: "rtl" });
100+
});

‎apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { type RuntimeRequestId, type ScopedThreadRef } from "@t3tools/contracts";
2+
import { markdownProseDirection, textDirection } from "~/textDirection";
23
import { memo, use, useEffect, useEffectEvent, useRef, useState } from "react";
34
import { BackgroundThreadContext } from "./backgroundThread";
45
import { useStableCallback } from "~/hooks/useStableCallback";
@@ -179,8 +180,20 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
179180
return null;
180181
}
181182

183+
const promptDirection = markdownProseDirection(
184+
prompt.questions
185+
.flatMap((question) => [
186+
question.header,
187+
question.question,
188+
...question.options.flatMap((option) => [option.label, option.description ?? ""]),
189+
])
190+
.join(" "),
191+
);
192+
const questionTextAlign = promptDirection === "rtl" ? "text-right" : "text-left";
193+
182194
return (
183195
<Collapsible
196+
dir={promptDirection}
184197
open={!isCollapsed}
185198
onOpenChange={(open) => {
186199
setCollapsedQuestionId(open ? null : activeQuestion.id);
@@ -197,13 +210,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
197210
<ComposerBanner.Content>
198211
<span
199212
className={cn("min-w-0 truncate text-muted-foreground", isCollapsed && "max-w-1/2")}
213+
dir={textDirection(activeQuestion.header)}
200214
>
201215
{activeQuestion.header}
202216
</span>
203217
{isCollapsed ? (
204218
<>
205219
<ComposerBanner.Separator />
206-
<span className="min-w-0 flex-1 truncate text-secondary-label">
220+
<span
221+
className={cn("min-w-0 flex-1 truncate text-secondary-label", questionTextAlign)}
222+
dir={markdownProseDirection(activeQuestion.question)}
223+
>
207224
{activeQuestion.question}
208225
</span>
209226
</>
@@ -245,7 +262,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
245262
text={activeQuestion.question}
246263
cwd={cwd}
247264
threadRef={threadRef}
248-
className="pt-2 text-foreground/85"
265+
className={cn("pt-2 text-foreground/85", questionTextAlign)}
249266
/>
250267
{/* Rows span the header row's width so the radios align with the question text and the shortcut column sits under the dismiss button. */}
251268
<div className="-ms-2 mt-1.5 sm:-ms-1.5">
@@ -257,7 +274,8 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
257274
const isSelected =
258275
isOptimisticallySelected || draftSelectedOptions?.[index] === true;
259276
const className = cn(
260-
"group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 text-left sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25",
277+
questionTextAlign,
278+
"group flex w-full items-start gap-2.5 rounded-md bg-transparent py-1.5 ps-2 sm:ps-1.5 outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25",
261279
isSelected ? "text-foreground" : "text-foreground/70 hover:bg-muted/30",
262280
(disabled || isResponding) && "opacity-50 cursor-not-allowed",
263281
!disabled && !isResponding && "cursor-pointer",
@@ -273,9 +291,16 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
273291
)}
274292
</span>
275293
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
276-
<span className="text-sm font-medium">{option.label}</span>
294+
<span className="text-sm font-medium" dir={textDirection(option.label)}>
295+
{option.label}
296+
</span>
277297
{option.description && option.description !== option.label ? (
278-
<span className="text-secondary-label text-2xs">{option.description}</span>
298+
<span
299+
className="text-secondary-label text-2xs"
300+
dir={textDirection(option.description)}
301+
>
302+
{option.description}
303+
</span>
279304
) : null}
280305
</div>
281306
{index < 9 ? (

‎apps/web/src/components/chat/MarkdownCodeBlock.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function MarkdownCodeBlockFrame({
6565
return (
6666
<Wrapper
6767
dir="ltr"
68-
className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
68+
className="chat-markdown-codeblock my-[0.65rem] text-start overflow-hidden rounded-lg border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
6969
data-language={language}
7070
data-wrap={wrapped ? "true" : "false"}
7171
>

‎apps/web/src/components/chat/MessagesTimeline.browser.test.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,7 @@ describe("MessagesTimeline attachments", () => {
859859
onOpen={onOpen}
860860
/>
861861
);
862-
await using screen = await renderElement(preview());
862+
await using screen = await renderElement(<div dir="rtl">{preview()}</div>);
863863
const audio = screen.container.querySelector("audio")!;
864864
const duration = vi.spyOn(audio, "duration", "get").mockReturnValue(Infinity);
865865
const play = vi.spyOn(audio, "play").mockResolvedValue();
@@ -884,6 +884,10 @@ describe("MessagesTimeline attachments", () => {
884884
audio.dispatchEvent(new Event("durationchange"));
885885
});
886886
expect(screen.text()).toContain("0:03 / 0:10");
887+
expect(
888+
getComputedStyle(screen.container.querySelector("audio")!.previousElementSibling!)
889+
.direction,
890+
).toBe("ltr");
887891
expect(progress.style.width).toBe("30%");
888892
await act(() => {
889893
duration.mockReturnValue(NaN);

0 commit comments

Comments
 (0)