Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
64ac893
fix(web,mobile): Hebrew and Arabic agent questions read right to left
Dananz Oct 23, 2026
23b7bf3
fix(web): keep question rows left to right, flip only their text
Dananz Oct 23, 2026
6613d45
refactor: share textDirection through a re-export instead of moving it
Dananz Oct 23, 2026
f754d62
test(web): assert computed direction and mock only ChatMarkdown's def…
Dananz Oct 23, 2026
d6f032d
test(web): cover the collapsed question preview's direction
Dananz Oct 23, 2026
7465cd1
Merge branch 'main' of https://github.com/pingdotgg/t3code into fix/q…
Dananz Oct 25, 2026
96be448
chore: merge main into question text direction fix
maria-rcks Oct 26, 2026
e21aaf8
fix(web): Hebrew and Arabic citation chips and comments read right to…
Dananz Oct 27, 2026
12a9d52
fix(web): one direction for the whole question set so the card never …
Dananz Oct 27, 2026
88ccf5e
fix(mobile): one direction for the whole question set, question text …
Dananz Oct 27, 2026
c2c5b94
Merge branch 'main' of https://github.com/pingdotgg/t3code into fix/q…
Dananz Oct 28, 2026
9ad99a3
Merge branch 'main' of https://github.com/pingdotgg/t3code into fix/q…
Dananz Oct 29, 2026
a7881bf
fix(web): attachments in a Hebrew or Arabic message sit on the right
Dananz Oct 29, 2026
7696fe3
fix: code blocks and URLs no longer pull a Hebrew message or question…
Dananz Oct 29, 2026
d54b84a
fix(web): code blocks stay left-aligned inside right-aligned text
Dananz Oct 29, 2026
da93945
fix(web): Hebrew and Arabic diff comments read right to left
Dananz Oct 29, 2026
d9baeb1
fix(web,mobile): Hebrew and Arabic read right to left in more text bo…
Dananz Oct 29, 2026
21421ab
fix(web): keep search sender labels on the left
Dananz Oct 29, 2026
61e7c5e
fix(web): left-align bidirectional search snippets
Dananz Oct 29, 2026
cd84f62
fix(mobile): left-align titles while preserving bidi text
Dananz Oct 29, 2026
fd00983
fix(web): left-align sidebar titles while preserving bidi
Dananz Oct 29, 2026
c94fc83
fix: isolate markdown preview code and cover slim title direction
Dananz Oct 29, 2026
1aec1e7
fix: apply native Android paragraph direction to thread titles
Dananz Oct 29, 2026
e4a4b03
fix: address RTL review in question cards and message previews
Dananz Oct 29, 2026
96e90d6
style: use utility class for inline code bidi isolation
Dananz Oct 29, 2026
6ed1c77
Merge branch 'main' into fix/question-card-rtl
Dananz Oct 29, 2026
44c9e8a
chore: merge main
maria-rcks Oct 29, 2026
9d0608a
chore: merge main into question rtl fix
maria-rcks Oct 30, 2026
f002666
fix(mobile): keep iOS composer text left aligned
Dananz Oct 30, 2026
adfbb27
fix(mobile): match desktop bidi alignment in iOS composer
Dananz Oct 30, 2026
204a4fa
chore: merge main into question rtl fix
maria-rcks Oct 30, 2026
3e9b079
chore: merge main into question rtl fix
maria-rcks Oct 31, 2026
b48cc5f
fix: preserve native paragraph alignment and audio time order
maria-rcks Oct 31, 2026
7263aa8
merge: sync RTL follow-up with updated question-card base
Dananz Oct 31, 2026
f030359
merge: resolve RTL branch against current main
Dananz Oct 31, 2026
2c8184c
fix(mobile): exclude URLs from composer direction detection
Dananz Oct 31, 2026
3d1216e
fix(mobile): preserve native Markdown syntax when seeding bidi
Dananz Oct 31, 2026
bc7a028
chore: merge main into rtl follow-up
maria-rcks Nov 1, 2026
bdf31f5
fix(mobile): preserve standalone markdown images in rtl questions
maria-rcks Nov 1, 2026
4c4395a
fix(mobile): preserve linked images in rtl questions
maria-rcks Nov 1, 2026
10f23c8
fix(web): preserve bidi ordering in sent citation comments
Dananz Nov 1, 2026
a6b3c65
fix(web): keep mixed question labels in their rtl context
Dananz Nov 1, 2026
7a4d991
Merge remote-tracking branch 'origin/main' into fix/more-rtl
Dananz Nov 2, 2026
d22a442
Merge remote-tracking branch 'origin/main' into HEAD
Dananz Nov 3, 2026
5cfa74e
fix(web,mobile): Hebrew and Arabic secret request cards read right to…
Dananz Nov 4, 2026
41a7488
fix(web,mobile): Hebrew and Arabic link request reasons read right to…
Dananz Nov 4, 2026
c3bfcd1
Merge remote-tracking branch 'github/main' into fix/more-rtl
maria-rcks Nov 5, 2026
db84490
Merge remote-tracking branch 'origin/main' into HEAD
Dananz Nov 6, 2026
30506f7
test(web): secret request direction test follows main's password fiel…
Dananz Nov 6, 2026
615d4e5
Merge remote-tracking branch 'github/main' into fix/more-rtl
maria-rcks Nov 6, 2026
6286ab6
Merge remote-tracking branch 'github/main' into fix/more-rtl
maria-rcks Nov 6, 2026
b6dbd74
fix(web): Hebrew and Arabic created-thread titles keep their bidi order
Dananz Nov 8, 2026
28ee2cf
fix(web,mobile): new text takes its own reading direction by default
Dananz Nov 8, 2026
ca79ca3
fix(web): leave inline text under an explicit dir alone
Dananz Nov 8, 2026
37d50d7
Merge remote-tracking branch 'github/main' into pr-27019
maria-rcks Nov 10, 2026
29e1b73
Fix RTL alignment of composer prompt suggestions
Dananz Nov 10, 2026
e13c2e5
Merge main into fix/more-rtl
maria-rcks Nov 11, 2026
caa63e2
Merge main into fix/more-rtl
Dananz Nov 11, 2026
83b08e6
Merge remote-tracking branch 'origin/main' into HEAD
Dananz Nov 13, 2026
564075a
Merge remote-tracking branch 'origin/main' into fix/more-rtl
juliusmarminge Nov 14, 2026
75e0854
test: keep the RTL tests that catch real regressions
juliusmarminge Nov 14, 2026
be8527a
fix(web,mobile): search snippets read in their own direction; cheap m…
juliusmarminge Nov 14, 2026
444ac42
Merge remote-tracking branch 'origin/main' into fix/more-rtl
juliusmarminge Nov 14, 2026
2fe79fd
fix(web): keep user message link stripping linear
juliusmarminge Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,16 @@ private struct ComposerControlledDocumentPayload: Decodable {
let tokensJson: String
let mostRecentEventCount: Int
let isNativeEcho: Bool
let paragraphDirections: [ComposerParagraphDirectionPayload]?
let resetGeneration: Int
}

private struct ComposerParagraphDirectionPayload: Decodable {
let start: Int
let end: Int
let direction: String
}

private struct ComposerThemePayload: Decodable {
let text: String
let placeholder: String
Expand Down Expand Up @@ -453,6 +460,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
private var value = ""
private var tokensJson = "[]"
private var tokens: [ComposerTokenPayload] = []
private var paragraphDirections: [ComposerParagraphDirectionPayload] = []
private var paragraphDirectionValue = ""
private var requestedSelection: ComposerSelectionPayload?
private var theme = ComposerThemePayload(
text: "#262626",
Expand Down Expand Up @@ -719,6 +728,8 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
}
let previousSelection = sourceSelection()
value = document.value
paragraphDirections = document.paragraphDirections ?? []
paragraphDirectionValue = document.value
requestedSelection = document.selection
applyControlledDocument(force: tokensNeedRebuild)
applyRequestedSelection()
Expand Down Expand Up @@ -940,6 +951,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
let shouldForce = force || hasDeferredForcedRebuild
let currentSource = textView.serializedText()
guard shouldForce || currentSource != value || !documentMatchesExpectedTokens() else {
applyParagraphDirections()
updatePlaceholderVisibility()
return
}
Expand All @@ -955,6 +967,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
requestedSelection = nil
textView.selectedRange = displayRange(for: targetSelection)
restoreBaseTypingAttributes()
applyParagraphDirections()
isApplyingControlledValue = false
if currentSource != value || previousSelection.start != targetSelection.start || previousSelection.end != targetSelection.end {
revealSelectionAfterLayout()
Expand Down Expand Up @@ -1140,19 +1153,57 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
return nil
}

private func baseAttributes() -> [NSAttributedString.Key: Any] {
let font = UIFont(name: fontFamily, size: fontSize)
?? UIFont.systemFont(ofSize: fontSize)
private func paragraphStyle(direction: String? = nil) -> NSMutableParagraphStyle {
let paragraph = NSMutableParagraphStyle()
if let direction {
paragraph.alignment = direction == "rtl" ? .right : .left
paragraph.baseWritingDirection = direction == "rtl" ? .rightToLeft : .leftToRight
}
paragraph.minimumLineHeight = lineHeight
paragraph.maximumLineHeight = lineHeight
return paragraph
}

private func baseAttributes() -> [NSAttributedString.Key: Any] {
let font = UIFont(name: fontFamily, size: fontSize)
?? UIFont.systemFont(ofSize: fontSize)
return [
.font: font,
.foregroundColor: UIColor(composerHex: theme.text) ?? .label,
.paragraphStyle: paragraph,
.paragraphStyle: paragraphStyle(),
]
}

private func applyParagraphDirections() {
guard !paragraphDirections.isEmpty, textView.markedTextRange == nil,
textView.serializedText() == paragraphDirectionValue else { return }
let wasApplyingControlledValue = isApplyingControlledValue
isApplyingControlledValue = true
let selection = textView.selectedRange
let sourceLength = (paragraphDirectionValue as NSString).length
textView.textStorage.beginEditing()
for paragraph in paragraphDirections {
guard paragraph.start >= 0, paragraph.end >= paragraph.start,
paragraph.end <= sourceLength else { continue }
let range = displayRange(for: ComposerSelectionPayload(start: paragraph.start, end: paragraph.end))
guard range.length > 0 else { continue }
let style = paragraphStyle(direction: paragraph.direction)
let current = textView.textStorage.attribute(.paragraphStyle, at: range.location, effectiveRange: nil) as? NSParagraphStyle
if current != style {
textView.textStorage.addAttribute(.paragraphStyle, value: style, range: range)
}
}
textView.textStorage.endEditing()
if textView.selectedRange != selection { textView.selectedRange = selection }
let sourceCaret = textView.sourceOffset(forDisplayOffset: selection.location)
if let paragraph = paragraphDirections.last(where: { $0.start <= sourceCaret }) {
var attributes = baseAttributes()
attributes[.paragraphStyle] = paragraphStyle(direction: paragraph.direction)
textView.typingAttributes = attributes
}
isApplyingControlledValue = wasApplyingControlledValue
}

private func applyTypography() {
let font = UIFont(name: fontFamily, size: fontSize)
?? UIFont.systemFont(ofSize: fontSize)
Expand All @@ -1166,7 +1217,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
guard textView.markedTextRange == nil else {
return
}
textView.typingAttributes = baseAttributes()
var attributes = baseAttributes()
// UIKit selects the paragraph's style when the caret moves; retain its bidi direction.
if let paragraph = textView.typingAttributes[.paragraphStyle] {
attributes[.paragraphStyle] = paragraph
}
textView.typingAttributes = attributes
}

private func applyTheme() {
Expand Down
39 changes: 39 additions & 0 deletions apps/mobile/modules/t3-composer-editor/tests/ios/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,45 @@ do {
)
}

// Paragraph directions align and read each paragraph without changing source text or caret.
do {
let view = T3ComposerEditorView()
view.frame = CGRect(x: 0, y: 0, width: 350, height: 200)
let text = "React עדכון הקוד בעברית\nEnglish stays left.\n"
let start = ("React עדכון הקוד בעברית\n" as NSString).length
let count = (text as NSString).length
var payload: [String: Any] = [
"value": text, "tokensJson": "[]", "mostRecentEventCount": 0, "isNativeEcho": false,
"resetGeneration": 0, "selection": ["start": count, "end": count],
"paragraphDirections": [
["start": 0, "end": start, "direction": "rtl"],
["start": start, "end": count, "direction": "ltr"],
["start": count, "end": count, "direction": "ltr"],
],
]
view.setControlledDocumentJson(String(data: try! JSONSerialization.data(withJSONObject: payload), encoding: .utf8)!)
let input = view.editor
check(input.attributedText.string == text, "Source text changed")
check(input.selectedRange == NSRange(location: count, length: 0), "Caret changed")
let rtl = input.textStorage.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as! NSParagraphStyle
let ltr = input.textStorage.attribute(.paragraphStyle, at: start, effectiveRange: nil) as! NSParagraphStyle
check(rtl.alignment == .right && rtl.baseWritingDirection == .rightToLeft, "Hebrew must align and read RTL")
check(ltr.alignment == .left && ltr.baseWritingDirection == .leftToRight, "English must align and read LTR")
view.layoutSubviews()
input.layoutManager.ensureLayout(for: input.textContainer)
let rtlGlyphs = input.layoutManager.glyphRange(forCharacterRange: NSRange(location: 0, length: start - 1), actualCharacterRange: nil)
let ltrGlyphs = input.layoutManager.glyphRange(forCharacterRange: NSRange(location: start, length: count - start - 1), actualCharacterRange: nil)
let rtlBounds = input.layoutManager.boundingRect(forGlyphRange: rtlGlyphs, in: input.textContainer)
let ltrBounds = input.layoutManager.boundingRect(forGlyphRange: ltrGlyphs, in: input.textContainer)
check(rtlBounds.maxX > 340 && ltrBounds.minX < 1, "Rendered paragraphs must use opposite edges")
let originalStorage = input.textStorage
payload["isNativeEcho"] = true
view.setControlledDocumentJson(String(data: try! JSONSerialization.data(withJSONObject: payload), encoding: .utf8)!)
check(input.textStorage === originalStorage && input.selectedRange.location == count, "Echo must preserve text storage and caret")
let typing = input.typingAttributes[.paragraphStyle] as! NSParagraphStyle
check(typing.baseWritingDirection == .leftToRight, "Trailing empty paragraph must inherit English")
}

print("composer editor tests passed")

private let dropEditor = ComposerTextView()
Expand Down
48 changes: 48 additions & 0 deletions apps/mobile/src/components/ui/AppText.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vite-plus/test";

vi.mock("react-native", () => ({
Platform: { OS: process.env.T3_MOBILE_TEST_PLATFORM ?? "ios" },
Text: (props: { readonly style?: unknown; readonly children?: string }) => (
<span data-style={JSON.stringify(props.style ?? null)}>{props.children}</span>
),
TextInput: () => null,
}));

import { AppText } from "./AppText";

function renderText(node: React.ReactNode) {
const host = document.createElement("div");
act(() => createRoot(host).render(node));
const span = host.querySelector("span")!;
return { text: span.textContent, style: JSON.parse(span.dataset.style!) };
}

it("sets the writing direction from plain-string text", () => {
expect(renderText(<AppText>סיכום פגישה: Roadmap</AppText>).style).toEqual([
{ writingDirection: "rtl" },
null,
]);
expect(renderText(<AppText>Weekly review</AppText>).style).toEqual([
{ writingDirection: "ltr" },
null,
]);
expect(renderText(<AppText>12:30</AppText>).style).toBeNull();
});

it("lets an explicit style win and leaves non-string children alone", () => {
expect(renderText(<AppText style={{ writingDirection: "ltr" }}>שלום</AppText>).style).toEqual([
{ writingDirection: "rtl" },
{ writingDirection: "ltr" },
]);
expect(renderText(<AppText>{["שלום", " 3"]}</AppText>).style).toBeNull();
});

// Metro inlines Platform.OS, so this branch runs under T3_MOBILE_TEST_PLATFORM=android.
it("marks the direction only for Android, which ignores writingDirection", () => {
const android = process.env.T3_MOBILE_TEST_PLATFORM === "android";
expect(renderText(<AppText>שלום world</AppText>).text).toBe(
android ? "\u200fשלום world" : "שלום world",
);
});
11 changes: 9 additions & 2 deletions apps/mobile/src/components/ui/AppText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import {
import { MOBILE_TEXT_CLASSES } from "../../lib/typography";

import { cn } from "../../lib/cn";
import { directionalText } from "../../lib/directionalText";
import { textDirection } from "@t3tools/shared/textDirection";

export type AppTextProps = RNTextProps & {
readonly className?: string;
readonly textRole?: keyof typeof MOBILE_TEXT_CLASSES;
};

export function AppText({ className, textRole, ...props }: AppTextProps) {
export function AppText({ className, textRole, style, children, ...props }: AppTextProps) {
// Plain-string text takes its own reading direction, so Hebrew and Arabic read right to left anywhere.
const direction = typeof children === "string" ? textDirection(children) : undefined;
return (
<RNText
className={cn(
Expand All @@ -25,8 +29,11 @@ export function AppText({ className, textRole, ...props }: AppTextProps) {
className,
)}
selectionColorClassName={Platform.OS === "android" ? "accent-focus/32" : undefined}
style={direction ? [{ writingDirection: direction }, style] : style}
{...props}
/>
>
{typeof children === "string" ? directionalText(children) : children}
</RNText>
);
}

Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/components/ui/SecretTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export function SecretTextInput(props: SecretTextInputProps) {
className="pr-12"
accessibilityLabel={props.accessibilityLabel}
placeholder={props.placeholder}
style={
props.textDirection
? {
writingDirection: props.textDirection,
textAlign: props.textDirection === "rtl" ? "right" : "left",
}
: undefined
}
value={props.value}
onChangeText={props.onChangeText}
editable={props.editable}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/ui/SecretTextInput.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export interface SecretTextInputProps {
/** Receives the field's current text, so a submit never uses a stale render of it. */
readonly onSubmitEditing: (value: string) => void;
readonly editable: boolean;
readonly textDirection?: "ltr" | "rtl";
}
9 changes: 8 additions & 1 deletion apps/mobile/src/features/threads/LinkRequestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type EnvironmentId,
type OrchestrationV2ProjectedTurnItem,
} from "@t3tools/contracts";
import { textDirection } from "@t3tools/shared/textDirection";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import { useMemo, useRef, useState } from "react";
Expand All @@ -40,6 +41,7 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow";
import { SettingsSection } from "../settings/components/SettingsSection";
import { Button } from "../../components/ui/Button";
import { directionalText, proseDirectionStyle } from "../../lib/directionalText";
import { Sheet } from "../../components/ui/Sheet";
import { RUNTIME_MODE_CHOICES } from "./thread-settings-options";

Expand Down Expand Up @@ -139,7 +141,12 @@ function PendingLinkRequest(props: {
: `Link ${target} to this environment`}
</Text>
{item.reason.trim() ? (
<Text className="font-sans text-sm leading-5 text-foreground-muted">{item.reason}</Text>
<Text
className="font-sans text-sm leading-5 text-foreground-muted"
style={proseDirectionStyle(item.reason, textDirection(item.reason))}
>
{directionalText(item.reason)}
</Text>
) : null}
</View>
{error !== null ? (
Expand Down
10 changes: 7 additions & 3 deletions apps/mobile/src/features/threads/PendingUserInputCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const harness = vi.hoisted(() => ({
type Children = { children?: ReactNode };

vi.mock("react-native", () => ({
Platform: { OS: "ios" },
Platform: { OS: process.env.T3_MOBILE_TEST_PLATFORM ?? "ios" },
View: ({ children }: Children) => <div>{children}</div>,
ScrollView: ({ children }: Children) => <div>{children}</div>,
Pressable: ({ children, onPress }: Children & { onPress?: () => void }) => (
Expand Down Expand Up @@ -127,8 +127,12 @@ function setup(questionMarkdown: string) {
};
}

it("opens a workspace file link in a question in the thread's file viewer", async () => {
using screen = setup("Should I update [probe.md](docs/probe.md:12)?");
it.each([
"Should I update [probe.md](docs/probe.md:12)?",
"האם לעדכן את הקובץ [probe.md](docs/probe.md:12)?",
"هل يجب تحديث الملف [probe.md](docs/probe.md:12)؟",
])("opens a workspace file link in the thread's file viewer: %s", async (markdown) => {
using screen = setup(markdown);

await screen.pressLink("probe.md");

Expand Down
Loading
Loading