diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 96551d05bbb55..a0c9549ca38dd 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -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 @@ -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", @@ -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() @@ -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 } @@ -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() @@ -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) @@ -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() { diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift index 4435b1022161d..7568b2dd116b6 100644 --- a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift +++ b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift @@ -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() diff --git a/apps/mobile/src/components/ui/AppText.test.tsx b/apps/mobile/src/components/ui/AppText.test.tsx new file mode 100644 index 0000000000000..725e269b7652d --- /dev/null +++ b/apps/mobile/src/components/ui/AppText.test.tsx @@ -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 }) => ( + {props.children} + ), + 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(סיכום פגישה: Roadmap).style).toEqual([ + { writingDirection: "rtl" }, + null, + ]); + expect(renderText(Weekly review).style).toEqual([ + { writingDirection: "ltr" }, + null, + ]); + expect(renderText(12:30).style).toBeNull(); +}); + +it("lets an explicit style win and leaves non-string children alone", () => { + expect(renderText(שלום).style).toEqual([ + { writingDirection: "rtl" }, + { writingDirection: "ltr" }, + ]); + expect(renderText({["שלום", " 3"]}).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(שלום world).text).toBe( + android ? "\u200fשלום world" : "שלום world", + ); +}); diff --git a/apps/mobile/src/components/ui/AppText.tsx b/apps/mobile/src/components/ui/AppText.tsx index 3ebcc8775f0b1..3af3e85e4332f 100644 --- a/apps/mobile/src/components/ui/AppText.tsx +++ b/apps/mobile/src/components/ui/AppText.tsx @@ -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 ( + > + {typeof children === "string" ? directionalText(children) : children} + ); } diff --git a/apps/mobile/src/components/ui/SecretTextInput.tsx b/apps/mobile/src/components/ui/SecretTextInput.tsx index 643fe5acbb63d..78e9894be69db 100644 --- a/apps/mobile/src/components/ui/SecretTextInput.tsx +++ b/apps/mobile/src/components/ui/SecretTextInput.tsx @@ -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} diff --git a/apps/mobile/src/components/ui/SecretTextInput.types.ts b/apps/mobile/src/components/ui/SecretTextInput.types.ts index 37db8e458d28d..e2c4e3ee07319 100644 --- a/apps/mobile/src/components/ui/SecretTextInput.types.ts +++ b/apps/mobile/src/components/ui/SecretTextInput.types.ts @@ -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"; } diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index aee091d9f598e..d6daa1045ec9e 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -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"; @@ -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"; @@ -139,7 +141,12 @@ function PendingLinkRequest(props: { : `Link ${target} to this environment`} {item.reason.trim() ? ( - {item.reason} + + {directionalText(item.reason)} + ) : null} {error !== null ? ( diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.test.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.test.tsx index 8dcfb787478e6..72bbf52f4c9fc 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.test.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.test.tsx @@ -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) =>
{children}
, ScrollView: ({ children }: Children) =>
{children}
, Pressable: ({ children, onPress }: Children & { onPress?: () => void }) => ( @@ -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"); diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index 12640fdb6e7e8..18f48f0481af8 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -4,10 +4,11 @@ import type { EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contrac import type { ThreadUserInputQuestion } from "@t3tools/client-runtime/state/thread-requests"; import { markdownProseDirection, - textDirection, + markdownWithDirection, type TextDirection, } from "@t3tools/shared/textDirection"; import { useNavigation } from "@react-navigation/native"; +import { directionalText, proseDirectionStyle } from "../../lib/directionalText"; import { useRef } from "react"; import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; import Animated, { @@ -67,14 +68,6 @@ export interface PendingUserInputCardProps { readonly onDismiss: () => Promise; } -function proseDirectionStyle(text: string, setDirection: TextDirection | undefined) { - const direction = textDirection(text); - return { - ...(direction ? { direction, writingDirection: direction } : null), - textAlign: (direction === "rtl") === (setDirection === "rtl") ? "left" : "right", - } as const; -} - function QuestionText(props: { readonly markdown: string; readonly environmentId: EnvironmentId; @@ -85,6 +78,7 @@ function QuestionText(props: { }) { const styles = useMarkdownPreviewStyles(); const markdown = useLiveThreadLinkLabels(props.markdown, props.environmentId); + const direction = markdownProseDirection(markdown); const navigation = useNavigation(); const openPullRequest = useOpenPullRequest(); const openLink = (href: string) => { @@ -106,12 +100,17 @@ function QuestionText(props: { }; return ( [ - question.header, - question.question, - ...question.options.flatMap((option) => [option.label, option.description ?? ""]), - ]) - .join(" "), + ...props.pendingUserInput.questions.flatMap((question) => [ + question.header, + question.question, + ...question.options.flatMap((option) => [option.label, option.description ?? ""]), + ]), ); const { cardCoverage, cardProgress, maxHeight } = props; @@ -265,7 +262,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className="font-t3-bold text-xs uppercase tracking-[1px] text-foreground-muted" style={proseDirectionStyle(question.header, setDirection)} > - {question.header} + {directionalText(question.header)} - {option.label} + {directionalText(option.label)} {description ? ( - {description} + {directionalText(description)} ) : null} diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index bdcef6f48ef7f..5de5b137945e2 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -15,6 +15,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { markdownProseDirection, textDirection } from "@t3tools/shared/textDirection"; import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, @@ -31,6 +32,7 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { Button } from "../../components/ui/Button"; +import { directionalText, proseDirectionStyle } from "../../lib/directionalText"; import { Chip } from "../../components/ui/Chip"; const LOCK_SYMBOL: AppSymbolName = { ios: "lock", android: "lock" }; @@ -63,7 +65,7 @@ export function SecretRequestCard(props: { - {item.label} · {display.label} + {`\u2068${item.label}\u2069 · ${display.label}`} ); @@ -91,6 +93,9 @@ function PendingSecretRequestForm(props: { const [error, setError] = useState(null); // Submit then a tap can both run before a re-render; this guard is synchronous. const inFlight = useRef(false); + const cardDirection = markdownProseDirection(item.label, item.reason); + const placeholder = item.placeholder ?? SECRET_REQUEST_DEFAULT_PLACEHOLDER; + const inputDirection = secret ? "ltr" : textDirection(placeholder); const send = async (reply: SecretRequestReply) => { const input = secretRequestAnswerInput(item, reply); @@ -126,10 +131,19 @@ function PendingSecretRequestForm(props: { return ( - {item.label} + + {directionalText(item.label)} + {item.reason.trim() ? ( - - {item.reason} + + {directionalText(item.reason)} ) : null} @@ -179,7 +193,8 @@ function PendingSecretRequestForm(props: { {source === null ? ( - {pendingTask.title} + {directionalText(pendingTask.title)} {isDraft ? ( @@ -1186,6 +1204,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { isRegeneratingTitle && "opacity-55", )} numberOfLines={2} + style={textDirectionStyle(thread.title)} onTextLayout={ Platform.OS === "android" ? ({ nativeEvent }) => { @@ -1197,7 +1216,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : undefined } > - {thread.title} + {directionalText(thread.title)} {props.hasQueuedMessages ? : null} @@ -1506,8 +1525,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { isRegeneratingTitle && "opacity-55", )} numberOfLines={1} + style={textDirectionStyle(thread.title)} > - {thread.title} + {directionalText(thread.title)} {providerNameLabel ? ( + {isUser ? "You:" : "Agent:"}{" "} - {parts.map((part) => ( - - {part.text} - - ))} - + {/* Its own paragraph, so the English label cannot set the snippet's base direction. */} + + {/* Android ignores writingDirection; the mark sets its first-strong paragraph direction. */} + {Platform.OS === "android" && direction + ? direction === "rtl" + ? "\u200f" + : "\u200e" + : null} + {parts.map((part) => ( + + {part.text} + + ))} + + ); } diff --git a/apps/mobile/src/lib/directionalText.ts b/apps/mobile/src/lib/directionalText.ts new file mode 100644 index 0000000000000..4ec92b98063d3 --- /dev/null +++ b/apps/mobile/src/lib/directionalText.ts @@ -0,0 +1,18 @@ +import { Platform } from "react-native"; +import { textDirection, type TextDirection } from "@t3tools/shared/textDirection"; + +export function directionalText(text: string) { + const direction = textDirection(text); + // Android ignores writingDirection; a render-only mark sets its first-strong paragraph direction. + return Platform.OS === "android" && direction + ? `${direction === "rtl" ? "\u200f" : "\u200e"}${text}` + : text; +} + +export function proseDirectionStyle(text: string, setDirection: TextDirection | undefined) { + const direction = textDirection(text); + return { + ...(direction ? { direction, writingDirection: direction } : null), + textAlign: (direction === "rtl") === (setDirection === "rtl") ? "left" : "right", + } as const; +} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index 437bcda4d012d..4d243ecc4be2d 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -1,6 +1,7 @@ import { PASTED_TEXT_ATTACHMENT_THRESHOLD_BYTES } from "@t3tools/client-runtime/text-paste"; import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS } from "@t3tools/contracts"; import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { composerParagraphDirections } from "@t3tools/shared/textDirection"; import { composerContextEditorTokens, composerSkillLabels } from "../lib/composerContext"; import { useComposerModelTokens } from "./useComposerModelTokens"; import { requireNativeView } from "expo"; @@ -182,10 +183,18 @@ export function ComposerEditor({ const skillLabels = useMemo(() => composerSkillLabels(skills), [skills]); const { modelsBySource, modelTokens } = useComposerModelTokens(props.value, models); + const editorTokens = useMemo( + () => composerContextEditorTokens(props.value, tokens, skillLabels, modelTokens), + [props.value, tokens, skillLabels, modelTokens], + ); + const paragraphDirections = useMemo( + () => composerParagraphDirections(props.value, editorTokens), + [props.value, editorTokens], + ); const chipTextColor = createNativeComposerTheme(theme).text; const tokensJson = useMemo(() => { return JSON.stringify( - composerContextEditorTokens(props.value, tokens, skillLabels, modelTokens).map((token) => { + editorTokens.map((token) => { const model = token.type === "model" ? modelsBySource.get(token.source) : undefined; const record = token.type === "context" @@ -223,16 +232,7 @@ export function ComposerEditor({ }; }), ); - }, [ - props.value, - tokens, - props.context, - skillLabels, - modelsBySource, - modelTokens, - theme, - chipTextColor, - ]); + }, [editorTokens, props.context, skillLabels, modelsBySource, theme, chipTextColor]); // Every render resolves against the snapshot history, so a render whose // (value, selection) lags the acknowledged native state is stamped behind // the native revision and rejected by the editor instead of re-applying a @@ -262,6 +262,7 @@ export function ComposerEditor({ value: props.value, selection: isNativeEcho ? null : (selection ?? null), tokensJson, + paragraphDirections, mostRecentEventCount: controlledEventCount, isNativeEcho, resetGeneration, diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 2b688fee30ad2..45dc9a0f432db 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -23,6 +23,7 @@ const reactTests = [ "src/components/ui/Dialog.android.test.tsx", "src/features/pull-requests/usePullRequestList.test.tsx", "src/components/ui/AppBar.android.test.tsx", + "src/components/ui/AppText.test.tsx", "src/features/files/ZipArchiveView.test.tsx", "src/features/pull-requests/guide/useReviewGuide.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", diff --git a/apps/web/src/bidiPlaintext.browser.test.tsx b/apps/web/src/bidiPlaintext.browser.test.tsx new file mode 100644 index 0000000000000..122635ade3f1f --- /dev/null +++ b/apps/web/src/bidiPlaintext.browser.test.tsx @@ -0,0 +1,41 @@ +import { expect, it } from "vite-plus/test"; + +import "~/index.css"; + +function mount(html: string) { + const host = document.createElement("div"); + host.style.width = "600px"; + host.innerHTML = html; + document.body.append(host); + return host; +} + +function wordLeft(element: Element, word: string) { + const text = [...element.childNodes].find((node) => node.textContent?.includes(word))!; + const range = document.createRange(); + const start = text.textContent!.indexOf(word); + range.setStart(text, start); + range.setEnd(text, start + word.length); + return range.getBoundingClientRect().left; +} + +it("gives plain text its own base direction", () => { + const host = mount(`באג: fix parser

Fix: באג parser

`); + const he = host.querySelector("#he")!; + const en = host.querySelector("#en")!; + expect(getComputedStyle(he).unicodeBidi).toBe("plaintext"); + expect(wordLeft(he, "באג")).toBeGreaterThan(wordLeft(he, "fix")); + expect(wordLeft(en, "Fix")).toBeLessThan(wordLeft(en, "parser")); + host.remove(); +}); + +it("leaves explicit dir, code and editors alone", () => { + const host = mount( + `

שלום

שלום
// הערה
` + + `x
שלום
`, + ); + for (const id of ["dir", "inline", "pre", "code", "edit"]) { + expect(getComputedStyle(host.querySelector(`#${id}`)!).unicodeBidi, id).not.toBe("plaintext"); + } + host.remove(); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4cfae88da1745..871b16f63074f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1455,7 +1455,12 @@ const CHAT_MARKDOWN_COMPONENTS = { ); } return ( - + {children} ); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 5a036e34611ed..276b1b9d63fcb 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1882,8 +1882,9 @@ function ComposerPromptEditorTiptapInner( /> {isEmpty && contextRecords.size === 0 && placeholder ? (
diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7ae210230a612..f11cc4ac5a212 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -128,6 +128,7 @@ import { sourceControlClients } from "@t3tools/client-runtime/source-control-cli import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; import { useOpenLink } from "~/browser/useOpenLink"; +import { textDirection } from "~/textDirection"; interface GitActionsControlProps { gitCwd: string | null; @@ -1949,6 +1950,7 @@ export default function GitActionsControl({

Commit message (optional)