From 670c6c9eac56ae64cc2e5135306f84d52846591b Mon Sep 17 00:00:00 2001 From: Mohamad Salah <98090972+Mo999salah@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:44:34 +0300 Subject: [PATCH 1/4] feat(web): add configurable RTL chat fonts Add a client setting for chat font selection and apply it across chat markdown, user messages, and the composer. Keep Arabic content readable in RTL while preserving code blocks and default app typography. Also fix the settings hook typing so components can subscribe to setting slices cleanly. --- apps/web/package.json | 1 + apps/web/src/components/ChatMarkdown.tsx | 18 +++++- .../src/components/ComposerPromptEditor.tsx | 26 +++++++- .../src/components/chat/MessagesTimeline.tsx | 26 +++++++- .../settings/SettingsAppearancePanel.tsx | 50 ++++++++++++++- .../settings/SettingsGeneralPanel.tsx | 2 + apps/web/src/hooks/useSettings.ts | 4 +- apps/web/src/index.css | 61 +++++++++++++++---- apps/web/src/lib/chatTypography.ts | 35 +++++++++++ apps/web/src/lib/textDirection.test.ts | 23 +++++++ apps/web/src/lib/textDirection.ts | 21 +++++++ apps/web/src/main.tsx | 3 + bun.lock | 3 + packages/contracts/src/settings.ts | 5 ++ 14 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/lib/chatTypography.ts create mode 100644 apps/web/src/lib/textDirection.test.ts create mode 100644 apps/web/src/lib/textDirection.ts diff --git a/apps/web/package.json b/apps/web/package.json index e871ae81..17e4b324 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource/dm-sans": "^5.2.8", + "@fontsource/noto-sans-arabic": "^5.2.10", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 525a4210..6437d063 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,10 +20,14 @@ import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; +import { useSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; import type { DiffsHighlighter, SupportedLanguages } from "@pierre/diffs"; +import { resolveChatTypographyClassName } from "~/lib/chatTypography"; +import { cn } from "~/lib/utils"; +import { resolveTextDirection } from "~/lib/textDirection"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -255,7 +259,9 @@ function SuspenseShikiCodeBlock({ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); + const chatFontFamily = useSettings((settings) => settings.chatFontFamily); const diffThemeName = resolveDiffThemeName(resolvedTheme); + const textDirection = useMemo(() => resolveTextDirection(text), [text]); const markdownUrlTransform = useCallback((href: string) => { return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href); }, []); @@ -310,7 +316,17 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { ); return ( -
+
settings.chatFontFamily); + const composerDirection = resolveTextDirection(value); + const composerTypographyClassName = useMemo( + () => + resolveChatTypographyClassName({ + direction: composerDirection, + fontFamily: chatFontFamily, + variant: "composer", + }), + [chatFontFamily, composerDirection], + ); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); const snapshotRef = useRef({ @@ -1092,8 +1106,11 @@ function ComposerPromptEditorInner({ 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 35ffd234..aa2fbfdf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -54,6 +54,7 @@ import { type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; +import { useSettings } from "../../hooks/useSettings"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; import { parseReviewReport, type ReviewFinding } from "../../review"; @@ -63,6 +64,8 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; +import { resolveChatTypographyClassName } from "~/lib/chatTypography"; +import { resolveTextDirection } from "~/lib/textDirection"; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -708,6 +711,17 @@ const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; }) { + const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const textDirection = resolveTextDirection(props.text); + const bodyClassName = cn( + "chat-message-inline-body whitespace-pre-wrap text-sm leading-relaxed text-foreground", + textDirection !== "rtl" && "font-mono", + resolveChatTypographyClassName({ + direction: textDirection, + fontFamily: chatFontFamily, + }), + ); + if (props.terminalContexts.length > 0) { const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( props.text, @@ -752,7 +766,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -780,7 +798,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); diff --git a/apps/web/src/components/settings/SettingsAppearancePanel.tsx b/apps/web/src/components/settings/SettingsAppearancePanel.tsx index 55f01dc0..9b0d2d24 100644 --- a/apps/web/src/components/settings/SettingsAppearancePanel.tsx +++ b/apps/web/src/components/settings/SettingsAppearancePanel.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_UNIFIED_SETTINGS, type ChatFontFamily } from "@t3tools/contracts/settings"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; @@ -23,6 +23,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const CHAT_FONT_LABELS = { + auto: "Auto", + "dm-sans": "DM Sans", + "noto-sans-arabic": "Noto Sans Arabic", +} as const satisfies Record; + export function SettingsAppearancePanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -64,6 +70,48 @@ export function SettingsAppearancePanel() { } /> + + updateSettings({ + chatFontFamily: DEFAULT_UNIFIED_SETTINGS.chatFontFamily, + }) + } + /> + ) : null + } + control={ + + } + /> + void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), + ...(settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily ? ["Chat font"] : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -87,6 +88,7 @@ export function useSettingsRestore(onRestored?: () => void) { isGitWritingModelDirty, isPromptEnhanceModelDirty, settings.askModelSelection, + settings.chatFontFamily, settings.codeModelSelection, settings.commitMessageStyle, settings.confirmThreadArchive, diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index dbcd3118..120731cb 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -63,9 +63,7 @@ function splitPatch(patch: SettingsUpdatePatch): { * only re-render when the slice they care about changes. */ -export function useSettings( - selector?: (s: UnifiedSettings) => T, -): T { +export function useSettings(selector?: (s: UnifiedSettings) => T): T { const serverSettings = useServerSettings(); const [clientSettings] = useLocalStorage( CLIENT_SETTINGS_STORAGE_KEY, diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 9c664b5b..159d8973 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -66,6 +66,12 @@ --desktop-window-safe-inset: 0px; --desktop-window-corner-radius: 0px; --radius: 0.625rem; + --font-sans-ui: + "DM Sans", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; + --font-sans-chat-rtl: + "Noto Sans Arabic", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, + sans-serif; --background: var(--color-white); --foreground: var(--color-neutral-800); --card: var(--color-white); @@ -123,13 +129,7 @@ } body { - font-family: - "DM Sans", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - system-ui, - sans-serif; + font-family: var(--font-sans-ui); margin: 0; padding: 0; min-height: 100vh; @@ -252,10 +252,43 @@ label:has(> select#reasoning-effort) select { } /* Chat markdown rendering */ +.chat-direction-rtl, +.chat-composer-direction-rtl { + direction: rtl; + text-align: start; + font-family: var(--font-sans-chat-rtl); +} + +.chat-direction-ltr, +.chat-composer-direction-ltr { + direction: ltr; + text-align: start; +} + +.chat-direction-auto, +.chat-composer-direction-auto { + text-align: start; +} + +.chat-font-dm-sans { + font-family: var(--font-sans-ui); +} + +.chat-font-noto-sans-arabic { + font-family: var(--font-sans-chat-rtl); +} + +.chat-message-inline-body { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + .chat-markdown { min-width: 0; overflow-wrap: anywhere; word-break: break-word; + text-align: start; } .chat-markdown > :first-child { @@ -276,12 +309,12 @@ label:has(> select#reasoning-effort) select { } .chat-markdown ul { - padding-left: 1.25rem; + padding-inline-start: 1.25rem; list-style-type: disc; } .chat-markdown ol { - padding-left: 1.25rem; + padding-inline-start: 1.25rem; list-style-type: decimal; } @@ -316,8 +349,8 @@ label:has(> select#reasoning-effort) select { } .chat-markdown blockquote { - border-left: 2px solid var(--border); - padding-left: 0.8rem; + border-inline-start: 2px solid var(--border); + padding-inline-start: 0.8rem; color: var(--muted-foreground); } @@ -328,6 +361,7 @@ label:has(> select#reasoning-effort) select { padding: 0.1rem 0.35rem; color: var(--foreground); font-size: 0.75rem; + direction: ltr; } .chat-markdown pre { @@ -350,6 +384,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown .chat-markdown-codeblock { position: relative; margin: 0.65rem 0; + direction: ltr; } .chat-markdown .chat-markdown-codeblock pre { @@ -359,7 +394,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown .chat-markdown-copy-button { position: absolute; top: 0.5rem; - right: 0.5rem; + inset-inline-end: 0.5rem; z-index: 1; display: inline-flex; align-items: center; @@ -402,7 +437,7 @@ label:has(> select#reasoning-effort) select { .chat-markdown td { border: 1px solid var(--border); padding: 0.35rem 0.45rem; - text-align: left; + text-align: start; } /* Diffs theme bridge (match diff surfaces to app palette) */ diff --git a/apps/web/src/lib/chatTypography.ts b/apps/web/src/lib/chatTypography.ts new file mode 100644 index 00000000..bb21ecb4 --- /dev/null +++ b/apps/web/src/lib/chatTypography.ts @@ -0,0 +1,35 @@ +import type { ChatFontFamily } from "@t3tools/contracts/settings"; +import type { TextDirection } from "./textDirection"; +import { cn } from "./utils"; + +type ChatTypographyVariant = "chat" | "composer"; + +const CHAT_DIRECTION_CLASS_BY_VARIANT = { + chat: { + rtl: "chat-direction-rtl", + ltr: "chat-direction-ltr", + auto: "chat-direction-auto", + }, + composer: { + rtl: "chat-composer-direction-rtl", + ltr: "chat-composer-direction-ltr", + auto: "chat-composer-direction-auto", + }, +} as const satisfies Record>; + +const CHAT_FONT_CLASS_BY_FAMILY = { + "dm-sans": "chat-font-dm-sans", + "noto-sans-arabic": "chat-font-noto-sans-arabic", +} as const satisfies Record, string>; + +export function resolveChatTypographyClassName(options: { + direction: TextDirection; + fontFamily: ChatFontFamily; + variant?: ChatTypographyVariant; +}) { + const variant = options.variant ?? "chat"; + return cn( + CHAT_DIRECTION_CLASS_BY_VARIANT[variant][options.direction], + options.fontFamily !== "auto" && CHAT_FONT_CLASS_BY_FAMILY[options.fontFamily], + ); +} diff --git a/apps/web/src/lib/textDirection.test.ts b/apps/web/src/lib/textDirection.test.ts new file mode 100644 index 00000000..c8510aaf --- /dev/null +++ b/apps/web/src/lib/textDirection.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { isRtlText, resolveTextDirection } from "./textDirection"; + +describe("resolveTextDirection", () => { + it("detects Arabic text as rtl", () => { + expect(resolveTextDirection("مرحبا بك في كودو")).toBe("rtl"); + expect(isRtlText("تحليل المشروع")).toBe(true); + }); + + it("treats Latin text as ltr", () => { + expect(resolveTextDirection("Review the latest diff")).toBe("ltr"); + expect(isRtlText("Run tests")).toBe(false); + }); + + it("ignores leading punctuation before the first strong rtl character", () => { + expect(resolveTextDirection('... "مرحبا"')).toBe("rtl"); + }); + + it("falls back to auto when no strong directional characters exist", () => { + expect(resolveTextDirection("1234 [] ()")).toBe("auto"); + }); +}); diff --git a/apps/web/src/lib/textDirection.ts b/apps/web/src/lib/textDirection.ts new file mode 100644 index 00000000..690e53c7 --- /dev/null +++ b/apps/web/src/lib/textDirection.ts @@ -0,0 +1,21 @@ +export type TextDirection = "ltr" | "rtl" | "auto"; + +const RTL_CHAR_REGEX = /[\u0591-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/; +const LTR_CHAR_REGEX = /[A-Za-z\u00C0-\u024F\u0370-\u03FF\u0400-\u052F]/; + +export function resolveTextDirection(text: string): TextDirection { + for (const character of text) { + if (RTL_CHAR_REGEX.test(character)) { + return "rtl"; + } + if (LTR_CHAR_REGEX.test(character)) { + return "ltr"; + } + } + + return "auto"; +} + +export function isRtlText(text: string): boolean { + return resolveTextDirection(text) === "rtl"; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 06037b9a..fc58d31f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,6 +6,9 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@fontsource/dm-sans/400.css"; import "@fontsource/dm-sans/500.css"; import "@fontsource/dm-sans/700.css"; +import "@fontsource/noto-sans-arabic/400.css"; +import "@fontsource/noto-sans-arabic/500.css"; +import "@fontsource/noto-sans-arabic/700.css"; import "@xterm/xterm/css/xterm.css"; import "./index.css"; diff --git a/bun.lock b/bun.lock index a65e3499..e70568ae 100644 --- a/bun.lock +++ b/bun.lock @@ -70,6 +70,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource/dm-sans": "^5.2.8", + "@fontsource/noto-sans-arabic": "^5.2.10", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", @@ -335,6 +336,8 @@ "@fontsource/dm-sans": ["@fontsource/dm-sans@5.2.8", "", {}, "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA=="], + "@fontsource/noto-sans-arabic": ["@fontsource/noto-sans-arabic@5.2.10", "", {}, "sha512-Hgj3NwQJ0NquIADW3hFboFjsts4UvEOJQ8tOX0bhQZgpNKHsMawjv/1SZnq0QvRbP6efHTZM/A1k7tdy9S6sbA=="], + "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 90be6c79..4c1f7318 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -29,7 +29,12 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const ChatFontFamily = Schema.Literals(["auto", "dm-sans", "noto-sans-arabic"]); +export type ChatFontFamily = typeof ChatFontFamily.Type; +export const DEFAULT_CHAT_FONT_FAMILY: ChatFontFamily = "auto"; + export const ClientSettingsSchema = Schema.Struct({ + chatFontFamily: ChatFontFamily.pipe(Schema.withDecodingDefault(() => DEFAULT_CHAT_FONT_FAMILY)), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), From 73f855a55fec3fff740ef0d0d7e6cb40f14dbec8 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:29:26 -0400 Subject: [PATCH 2/4] feat: expand multilingual chat typography Build on the RTL chat typography work with broader script support. Add bundled chat fallbacks for Hebrew, Devanagari, and Thai, keep Arabic within the multilingual stack, and harden virtualized row remeasurement for typography changes. --- apps/web/package.json | 4 + apps/web/src/components/ChatMarkdown.tsx | 9 +- .../src/components/ComposerPromptEditor.tsx | 21 ++- .../components/chat/MessagesTimeline.logic.ts | 4 +- .../src/components/chat/MessagesTimeline.tsx | 43 +++-- ...essagesTimeline.virtualization.browser.tsx | 157 ++++++++++++++++++ .../settings/SettingsAppearancePanel.tsx | 89 ++++++++-- .../settings/SettingsGeneralPanel.tsx | 8 +- .../web/src/components/timelineHeight.test.ts | 34 ++++ apps/web/src/components/timelineHeight.ts | 70 ++++++-- apps/web/src/index.css | 66 +++++--- apps/web/src/lib/chatReadability.test.ts | 41 +++++ apps/web/src/lib/chatReadability.ts | 34 ++++ apps/web/src/lib/chatTypography.ts | 35 ---- apps/web/src/lib/textDirection.test.ts | 5 + apps/web/src/main.tsx | 12 ++ bun.lock | 12 ++ packages/contracts/src/settings.ts | 12 +- 18 files changed, 540 insertions(+), 116 deletions(-) create mode 100644 apps/web/src/lib/chatReadability.test.ts create mode 100644 apps/web/src/lib/chatReadability.ts delete mode 100644 apps/web/src/lib/chatTypography.ts diff --git a/apps/web/package.json b/apps/web/package.json index 17e4b324..7c4aa1a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,11 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource/dm-sans": "^5.2.8", + "@fontsource/noto-sans": "^5.2.10", "@fontsource/noto-sans-arabic": "^5.2.10", + "@fontsource/noto-sans-devanagari": "^5.2.8", + "@fontsource/noto-sans-hebrew": "^5.2.8", + "@fontsource/noto-sans-thai": "^5.2.8", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 6437d063..46f4e597 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -25,7 +25,7 @@ import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget, rewriteMarkdownFileUriHref } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; import type { DiffsHighlighter, SupportedLanguages } from "@pierre/diffs"; -import { resolveChatTypographyClassName } from "~/lib/chatTypography"; +import { resolveChatReadabilityClassName } from "~/lib/chatReadability"; import { cn } from "~/lib/utils"; import { resolveTextDirection } from "~/lib/textDirection"; @@ -260,6 +260,7 @@ function SuspenseShikiCodeBlock({ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { const { resolvedTheme } = useTheme(); const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const chatTextSize = useSettings((settings) => settings.chatTextSize); const diffThemeName = resolveDiffThemeName(resolvedTheme); const textDirection = useMemo(() => resolveTextDirection(text), [text]); const markdownUrlTransform = useCallback((href: string) => { @@ -318,12 +319,12 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { return (
diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index c5a52e5d..14c3b3c8 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -66,7 +66,7 @@ import { import { cn } from "~/lib/utils"; import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons"; import { useSettings } from "../hooks/useSettings"; -import { resolveChatTypographyClassName } from "~/lib/chatTypography"; +import { resolveChatReadabilityClassName } from "~/lib/chatReadability"; import { resolveTextDirection } from "~/lib/textDirection"; import { COMPOSER_INLINE_CHIP_CLASS_NAME, @@ -898,15 +898,16 @@ function ComposerPromptEditorInner({ const onChangeRef = useRef(onChange); const initialCursor = clampCollapsedComposerCursor(value, cursor); const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const chatTextSize = useSettings((settings) => settings.chatTextSize); const composerDirection = resolveTextDirection(value); - const composerTypographyClassName = useMemo( + const composerReadabilityClassName = useMemo( () => - resolveChatTypographyClassName({ + resolveChatReadabilityClassName({ direction: composerDirection, fontFamily: chatFontFamily, - variant: "composer", + textSize: chatTextSize, }), - [chatFontFamily, composerDirection], + [chatFontFamily, chatTextSize, composerDirection], ); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); @@ -1107,10 +1108,9 @@ function ComposerPromptEditorInner({ contentEditable={ 0 ? null : (
{placeholder} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 16b02ea9..4b5ce371 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1,4 +1,4 @@ -import { type MessageId } from "@t3tools/contracts"; +import { type ChatTextSize, type MessageId } from "@t3tools/contracts"; import { type TimelineEntry, type WorkLogEntry } from "../../session-logic"; import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; @@ -133,6 +133,7 @@ export function estimateMessagesTimelineRowHeight( row: MessagesTimelineRow, input: { timelineWidthPx: number | null; + chatTextSize?: ChatTextSize; expandedWorkGroups?: Readonly>; turnDiffSummaryByAssistantMessageId?: ReadonlyMap; }, @@ -147,6 +148,7 @@ export function estimateMessagesTimelineRowHeight( case "message": { let estimate = estimateTimelineMessageHeight(row.message, { timelineWidthPx: input.timelineWidthPx, + ...(input.chatTextSize ? { chatTextSize: input.chatTextSize } : {}), }); const turnDiffSummary = input.turnDiffSummaryByAssistantMessageId?.get(row.message.id); if (turnDiffSummary && turnDiffSummary.files.length > 0) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index aa2fbfdf..6d18431d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -64,7 +64,7 @@ import { formatInlineTerminalContextLabel, textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; -import { resolveChatTypographyClassName } from "~/lib/chatTypography"; +import { resolveChatReadabilityClassName } from "~/lib/chatReadability"; import { resolveTextDirection } from "~/lib/textDirection"; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -98,6 +98,7 @@ interface MessagesTimelineProps { onReviewImplementAll?: (findings: ReviewFinding[]) => void; onReviewDismissAll?: () => void; onVirtualizerSnapshot?: (snapshot: { + measurementScopeKey: string; totalSize: number; measurements: ReadonlyArray<{ id: string; @@ -143,6 +144,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const timelineRootRef = useRef(null); const pendingMeasureFrameRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); + const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const chatTextSize = useSettings((settings) => settings.chatTextSize); + const typographyMeasurementScopeKey = `${chatFontFamily}:${chatTextSize}`; useLayoutEffect(() => { const timelineRoot = timelineRootRef.current; @@ -227,7 +231,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ maximum: rows.length, }); const virtualMeasurementScopeKey = - timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; + timelineWidthPx === null + ? `width:unknown:typography:${typographyMeasurementScopeKey}` + : `width:${Math.round(timelineWidthPx)}:typography:${typographyMeasurementScopeKey}`; const rowVirtualizer = useVirtualizer({ count: virtualizedRowCount, @@ -242,6 +248,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const row = rows[index]; if (!row) return 96; return estimateMessagesTimelineRowHeight(row, { + chatTextSize, expandedWorkGroups, timelineWidthPx, turnDiffSummaryByAssistantMessageId, @@ -262,6 +269,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (timelineWidthPx === null) return; rowVirtualizer.measure(); }, [rowVirtualizer, timelineWidthPx]); + useEffect(() => { + scheduleVirtualizerMeasure(); + }, [scheduleVirtualizerMeasure, typographyMeasurementScopeKey]); useEffect(() => { if (typeof document === "undefined" || document.fonts === undefined) { return; @@ -283,7 +293,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ fontSet.removeEventListener("loadingdone", handleFontSettle); fontSet.removeEventListener("loadingerror", handleFontSettle); }; - }, [scheduleVirtualizerMeasure]); + }, [scheduleVirtualizerMeasure, typographyMeasurementScopeKey]); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { const viewportHeight = instance.scrollRect?.height ?? 0; @@ -316,6 +326,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } onVirtualizerSnapshot({ + measurementScopeKey: typographyMeasurementScopeKey, totalSize: rowVirtualizer.getTotalSize(), measurements: rowVirtualizer.measurementsCache .slice(0, virtualizedRowCount) @@ -336,7 +347,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ]; }), }); - }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); + }, [ + onVirtualizerSnapshot, + rowVirtualizer, + rows, + typographyMeasurementScopeKey, + virtualizedRowCount, + ]); const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); @@ -712,13 +729,15 @@ const UserMessageBody = memo(function UserMessageBody(props: { terminalContexts: ParsedTerminalContextEntry[]; }) { const chatFontFamily = useSettings((settings) => settings.chatFontFamily); + const chatTextSize = useSettings((settings) => settings.chatTextSize); const textDirection = resolveTextDirection(props.text); const bodyClassName = cn( - "chat-message-inline-body whitespace-pre-wrap text-sm leading-relaxed text-foreground", + "chat-message-inline-body whitespace-pre-wrap text-foreground", textDirection !== "rtl" && "font-mono", - resolveChatTypographyClassName({ + resolveChatReadabilityClassName({ direction: textDirection, fontFamily: chatFontFamily, + textSize: chatTextSize, }), ); @@ -766,11 +785,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); @@ -798,11 +813,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -
+
{inlineNodes}
); diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index b021cecd..e95d35b3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -1,6 +1,7 @@ import "../../index.css"; import { MessageId, type TurnId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { page } from "vitest/browser"; import { useCallback, useState, type ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -19,6 +20,7 @@ const DEFAULT_VIEWPORT = { height: 1_100, }; const MARKDOWN_CWD = "/repo/project"; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; interface RowMeasurement { actualHeightPx: number; @@ -36,6 +38,7 @@ interface VirtualizationScenario { } interface VirtualizerSnapshot { + measurementScopeKey: string; totalSize: number; measurements: ReadonlyArray<{ id: string; @@ -407,6 +410,16 @@ function buildStaticScenarios(): VirtualizationScenario[] { ].join(" "), maxEstimateDeltaPx: 28, }), + createAssistantMessageScenario({ + name: "assistant mixed-direction row with inline code", + rowId: "target-assistant-mixed-direction", + text: [ + "Validate the bidi layout for `src/main.tsx` before shipping this branch.", + "مرحبا من واجهة الدردشة مع نص عربي وانجليزي مختلط.", + "שלום שוב כדי نتפוס עטיפה מימין לשמאל.", + ].join(" "), + maxEstimateDeltaPx: 40, + }), createChangedFilesScenario({ name: "assistant changed-files row with a compacted single-chain directory", rowId: "target-assistant-changed-files-single-chain", @@ -486,6 +499,33 @@ async function waitForDocumentFonts(): Promise { await waitForLayout(); } +async function setClientSettings(patch: Partial): Promise { + localStorage.setItem( + CLIENT_SETTINGS_STORAGE_KEY, + JSON.stringify({ + ...DEFAULT_CLIENT_SETTINGS, + ...patch, + }), + ); + window.dispatchEvent( + new CustomEvent("t3code:local_storage_change", { + detail: { key: CLIENT_SETTINGS_STORAGE_KEY }, + }), + ); + await waitForDocumentFonts(); + await waitForLayout(); +} + +function requireVirtualizerSnapshot( + snapshot: VirtualizerSnapshot | null, + message: string, +): VirtualizerSnapshot { + if (!snapshot) { + throw new Error(message); + } + return snapshot; +} + async function waitForElement( query: () => T | null, errorMessage: string, @@ -641,11 +681,13 @@ async function measureRenderedRowActualHeight(input: { describe("MessagesTimeline virtualization harness", () => { beforeEach(async () => { document.body.innerHTML = ""; + localStorage.clear(); await setViewport(DEFAULT_VIEWPORT); }); afterEach(() => { document.body.innerHTML = ""; + localStorage.clear(); }); it.each(buildStaticScenarios())("keeps the $name estimate within tolerance", async (scenario) => { @@ -902,6 +944,7 @@ describe("MessagesTimeline virtualization harness", () => { ]), onVirtualizerSnapshot: (snapshot) => { latestSnapshot = { + measurementScopeKey: snapshot.measurementScopeKey, totalSize: snapshot.totalSize, measurements: snapshot.measurements, }; @@ -996,6 +1039,7 @@ describe("MessagesTimeline virtualization harness", () => { messages: [...beforeMessages, targetMessage, ...afterMessages], onVirtualizerSnapshot: (snapshot) => { latestSnapshot = { + measurementScopeKey: snapshot.measurementScopeKey, totalSize: snapshot.totalSize, measurements: snapshot.measurements, }; @@ -1063,4 +1107,117 @@ describe("MessagesTimeline virtualization harness", () => { await mounted.cleanup(); } }); + + it("re-measures virtualized rows after chat text size changes", async () => { + const scenario = createAssistantMessageScenario({ + name: "assistant text-size remeasure", + rowId: "target-assistant-text-size-remeasure", + text: [ + "This message should wrap differently once the shared chat text size changes.", + "It needs enough content to create a visible virtualizer delta after the setting update.", + "We want the live timeline cache to invalidate instead of holding on to stale heights.", + ].join(" "), + maxEstimateDeltaPx: 40, + }); + + const mounted = await mountMessagesTimeline({ + props: scenario.props, + viewport: { width: 420, height: 760 }, + }); + + try { + const beforeChange = await measureTimelineRow({ + host: mounted.host, + props: scenario.props, + targetRowId: scenario.targetRowId, + }); + + await setClientSettings({ chatTextSize: "large" }); + + await vi.waitFor( + async () => { + const afterChange = await measureTimelineRow({ + host: mounted.host, + props: scenario.props, + targetRowId: scenario.targetRowId, + }); + expect(afterChange.actualHeightPx).toBeGreaterThan(beforeChange.actualHeightPx + 8); + expect( + Math.abs(afterChange.actualHeightPx - afterChange.virtualizerSizePx), + ).toBeLessThanOrEqual(8); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("re-measures virtualized rows after chat font family changes", async () => { + await setClientSettings({ chatFontFamily: "dm-sans" }); + + const scenario = createAssistantMessageScenario({ + name: "assistant font-family remeasure", + rowId: "target-assistant-font-family-remeasure", + text: [ + "Typography changes should invalidate the virtualizer cache for already measured rows.", + "This paragraph is narrow on purpose so a font-family switch can move line breaks enough to matter.", + "The browser view should settle on the new measured height instead of keeping the old cached size.", + ].join(" "), + maxEstimateDeltaPx: 40, + }); + + let latestSnapshot: VirtualizerSnapshot | null = null; + + const mounted = await mountMessagesTimeline({ + props: { + ...scenario.props, + onVirtualizerSnapshot: (snapshot) => { + latestSnapshot = { + measurementScopeKey: snapshot.measurementScopeKey, + totalSize: snapshot.totalSize, + measurements: snapshot.measurements, + }; + }, + }, + viewport: { width: 320, height: 760 }, + }); + + try { + await measureTimelineRow({ + host: mounted.host, + props: scenario.props, + targetRowId: scenario.targetRowId, + }); + const beforeScopeKey = requireVirtualizerSnapshot( + latestSnapshot, + "Expected an initial virtualizer snapshot before changing chat fonts.", + ).measurementScopeKey; + expect(beforeScopeKey).toBe("dm-sans:default"); + + await setClientSettings({ chatFontFamily: "noto-sans-multiscript" }); + + await vi.waitFor( + async () => { + const afterChange = await measureTimelineRow({ + host: mounted.host, + props: scenario.props, + targetRowId: scenario.targetRowId, + }); + const afterSnapshot = requireVirtualizerSnapshot( + latestSnapshot, + "Expected a virtualizer snapshot after changing chat fonts.", + ); + expect(afterSnapshot.measurementScopeKey).toBe("noto-sans-multiscript:default"); + expect(afterSnapshot.measurementScopeKey).not.toBe(beforeScopeKey); + expect( + Math.abs(afterChange.actualHeightPx - afterChange.virtualizerSizePx), + ).toBeLessThanOrEqual(8); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); }); diff --git a/apps/web/src/components/settings/SettingsAppearancePanel.tsx b/apps/web/src/components/settings/SettingsAppearancePanel.tsx index 9b0d2d24..9514e73f 100644 --- a/apps/web/src/components/settings/SettingsAppearancePanel.tsx +++ b/apps/web/src/components/settings/SettingsAppearancePanel.tsx @@ -1,4 +1,8 @@ -import { DEFAULT_UNIFIED_SETTINGS, type ChatFontFamily } from "@t3tools/contracts/settings"; +import { + DEFAULT_UNIFIED_SETTINGS, + type ChatFontFamily, + type ChatTextSize, +} from "@t3tools/contracts/settings"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; @@ -26,9 +30,30 @@ const TIMESTAMP_FORMAT_LABELS = { const CHAT_FONT_LABELS = { auto: "Auto", "dm-sans": "DM Sans", - "noto-sans-arabic": "Noto Sans Arabic", + "noto-sans": "Noto Sans", + "noto-sans-multiscript": "Noto Sans Multilingual", } as const satisfies Record; +const CHAT_FONT_OPTIONS = [ + { value: "auto", label: CHAT_FONT_LABELS.auto }, + { value: "dm-sans", label: CHAT_FONT_LABELS["dm-sans"] }, + { value: "noto-sans", label: CHAT_FONT_LABELS["noto-sans"] }, + { + value: "noto-sans-multiscript", + label: CHAT_FONT_LABELS["noto-sans-multiscript"], + }, +] as const satisfies ReadonlyArray<{ value: ChatFontFamily; label: string }>; + +function isChatFontFamily(value: string | null): value is ChatFontFamily { + return value !== null && CHAT_FONT_OPTIONS.some((option) => option.value === value); +} + +const CHAT_TEXT_SIZE_LABELS = { + small: "Small", + default: "Default", + large: "Large", +} as const satisfies Record; + export function SettingsAppearancePanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); @@ -71,12 +96,12 @@ export function SettingsAppearancePanel() { /> updateSettings({ chatFontFamily: DEFAULT_UNIFIED_SETTINGS.chatFontFamily, @@ -89,23 +114,61 @@ export function SettingsAppearancePanel() { + } + /> + + + updateSettings({ + chatTextSize: DEFAULT_UNIFIED_SETTINGS.chatTextSize, + }) + } + /> + ) : null + } + control={ + diff --git a/apps/web/src/components/settings/SettingsGeneralPanel.tsx b/apps/web/src/components/settings/SettingsGeneralPanel.tsx index e55eeec3..b3deb4a5 100644 --- a/apps/web/src/components/settings/SettingsGeneralPanel.tsx +++ b/apps/web/src/components/settings/SettingsGeneralPanel.tsx @@ -50,7 +50,12 @@ export function useSettingsRestore(onRestored?: () => void) { const changedSettingLabels = useMemo( () => [ ...(theme !== "system" ? ["Theme"] : []), - ...(settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily ? ["Chat font"] : []), + ...(settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily + ? ["Chat typography"] + : []), + ...(settings.chatTextSize !== DEFAULT_UNIFIED_SETTINGS.chatTextSize + ? ["Chat text size"] + : []), ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), @@ -89,6 +94,7 @@ export function useSettingsRestore(onRestored?: () => void) { isPromptEnhanceModelDirty, settings.askModelSelection, settings.chatFontFamily, + settings.chatTextSize, settings.codeModelSelection, settings.commitMessageStyle, settings.confirmThreadArchive, diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index a295a0b3..610c7876 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -125,6 +125,40 @@ describe("estimateTimelineMessageHeight", () => { expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(117.5); }); + it("increases message estimates when chat text size is larger", () => { + const assistantMessage = { + role: "assistant" as const, + text: "a".repeat(180), + }; + const userMessage = { + role: "user" as const, + text: "a".repeat(120), + }; + + expect( + estimateTimelineMessageHeight(assistantMessage, { + timelineWidthPx: 420, + chatTextSize: "large", + }), + ).toBeGreaterThan( + estimateTimelineMessageHeight(assistantMessage, { + timelineWidthPx: 420, + chatTextSize: "default", + }), + ); + expect( + estimateTimelineMessageHeight(userMessage, { + timelineWidthPx: 420, + chatTextSize: "large", + }), + ).toBeGreaterThan( + estimateTimelineMessageHeight(userMessage, { + timelineWidthPx: 420, + chatTextSize: "default", + }), + ); + }); + it("does not clamp user wrapping too aggressively on very narrow layouts", () => { const message = { role: "user" as const, diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 5b546d97..28e55f88 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -1,3 +1,4 @@ +import { type ChatTextSize } from "@t3tools/contracts/settings"; import { deriveDisplayedUserMessageState } from "../lib/terminalContext"; import { buildInlineTerminalContextText } from "./chat/userMessageTerminalContexts"; @@ -27,6 +28,36 @@ const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; +const TEXT_SIZE_WIDTH_SCALE = { + small: 13 / 14, + default: 1, + large: 15 / 14, +} as const satisfies Record; + +const USER_LINE_HEIGHT_PX_BY_TEXT_SIZE = { + small: 18.75, + default: USER_LINE_HEIGHT_PX, + large: 23.25, +} as const satisfies Record; + +const ASSISTANT_LINE_HEIGHT_PX_BY_TEXT_SIZE = { + small: 20.25, + default: ASSISTANT_LINE_HEIGHT_PX, + large: 25.5, +} as const satisfies Record; + +const USER_BASE_HEIGHT_PX_BY_TEXT_SIZE = { + small: 72, + default: USER_BASE_HEIGHT_PX, + large: 81, +} as const satisfies Record; + +const ASSISTANT_BASE_HEIGHT_PX_BY_TEXT_SIZE = { + small: 38, + default: ASSISTANT_BASE_HEIGHT_PX, + large: 45, +} as const satisfies Record; + interface TimelineMessageHeightInput { role: "user" | "assistant" | "system"; text: string; @@ -35,6 +66,7 @@ interface TimelineMessageHeightInput { interface TimelineHeightEstimateLayout { timelineWidthPx: number | null; + chatTextSize?: ChatTextSize; } function estimateWrappedLineCount(text: string, charsPerLine: number): number { @@ -63,30 +95,38 @@ function isFinitePositiveNumber(value: number | null | undefined): value is numb function estimateCharsPerLineForUser( timelineWidthPx: number | null, averageCharWidthPx: number, + chatTextSize: ChatTextSize, ): number { if (!isFinitePositiveNumber(timelineWidthPx)) return USER_CHARS_PER_LINE_FALLBACK; const bubbleWidthPx = timelineWidthPx * USER_BUBBLE_WIDTH_RATIO; const textWidthPx = Math.max(bubbleWidthPx - USER_BUBBLE_HORIZONTAL_PADDING_PX, 0); - return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / averageCharWidthPx)); + const scaledCharWidthPx = averageCharWidthPx * TEXT_SIZE_WIDTH_SCALE[chatTextSize]; + return Math.max(MIN_USER_CHARS_PER_LINE, Math.floor(textWidthPx / scaledCharWidthPx)); } -function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): number { +function estimateCharsPerLineForAssistant( + timelineWidthPx: number | null, + chatTextSize: ChatTextSize, +): number { if (!isFinitePositiveNumber(timelineWidthPx)) return ASSISTANT_CHARS_PER_LINE_FALLBACK; const textWidthPx = Math.max(timelineWidthPx - ASSISTANT_MESSAGE_HORIZONTAL_PADDING_PX, 0); - return Math.max( - MIN_ASSISTANT_CHARS_PER_LINE, - Math.floor(textWidthPx / ASSISTANT_AVG_CHAR_WIDTH_PX), - ); + const scaledCharWidthPx = ASSISTANT_AVG_CHAR_WIDTH_PX * TEXT_SIZE_WIDTH_SCALE[chatTextSize]; + return Math.max(MIN_ASSISTANT_CHARS_PER_LINE, Math.floor(textWidthPx / scaledCharWidthPx)); } export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { + const chatTextSize = layout.chatTextSize ?? "default"; + if (message.role === "assistant") { - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx, chatTextSize); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; + return ( + ASSISTANT_BASE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] + + estimatedLines * ASSISTANT_LINE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] + ); } if (message.role === "user") { @@ -109,17 +149,25 @@ export function estimateTimelineMessageHeight( displayedUserMessage.contexts.length > 0 ? USER_MONO_AVG_CHAR_WIDTH_PX : USER_MARKDOWN_AVG_CHAR_WIDTH_PX, + chatTextSize, ); const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine); const attachmentCount = message.attachments?.length ?? 0; const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW); const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX; - return USER_BASE_HEIGHT_PX + estimatedLines * USER_LINE_HEIGHT_PX + attachmentHeight; + return ( + USER_BASE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] + + estimatedLines * USER_LINE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] + + attachmentHeight + ); } // `system` messages are not rendered in the chat timeline, but keep a stable // explicit branch in case they are present in timeline data. - const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); + const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx, chatTextSize); const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); - return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; + return ( + ASSISTANT_BASE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] + + estimatedLines * ASSISTANT_LINE_HEIGHT_PX_BY_TEXT_SIZE[chatTextSize] + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 159d8973..38f2438f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -67,11 +67,16 @@ --desktop-window-corner-radius: 0px; --radius: 0.625rem; --font-sans-ui: - "DM Sans", "Noto Sans Arabic", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; - --font-sans-chat-rtl: - "Noto Sans Arabic", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, - sans-serif; + "DM Sans", "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Devanagari", + "Noto Sans Thai", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-sans-chat-dm-sans: + "DM Sans", "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Devanagari", + "Noto Sans Thai", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-sans-chat-noto: + "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-sans-chat-multiscript: + "Noto Sans", "Noto Sans Arabic", "Noto Sans Hebrew", "Noto Sans Devanagari", "Noto Sans Thai", + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --background: var(--color-white); --foreground: var(--color-neutral-800); --card: var(--color-white); @@ -251,31 +256,45 @@ label:has(> select#reasoning-effort) select { height: 100%; } -/* Chat markdown rendering */ -.chat-direction-rtl, -.chat-composer-direction-rtl { - direction: rtl; +/* Chat readability */ +.chat-readability-surface { text-align: start; - font-family: var(--font-sans-chat-rtl); + unicode-bidi: isolate; +} + +.chat-readability-direction-rtl { + direction: rtl; } -.chat-direction-ltr, -.chat-composer-direction-ltr { +.chat-readability-direction-ltr { direction: ltr; - text-align: start; } -.chat-direction-auto, -.chat-composer-direction-auto { - text-align: start; +.chat-readability-font-dm-sans { + font-family: var(--font-sans-chat-dm-sans); } -.chat-font-dm-sans { - font-family: var(--font-sans-ui); +.chat-readability-font-noto-sans { + font-family: var(--font-sans-chat-noto); +} + +.chat-readability-font-noto-sans-multiscript { + font-family: var(--font-sans-chat-multiscript); +} + +.chat-readability-text-small { + font-size: 0.8125rem; + line-height: 1.55; +} + +.chat-readability-text-default { + font-size: 0.875rem; + line-height: 1.625; } -.chat-font-noto-sans-arabic { - font-family: var(--font-sans-chat-rtl); +.chat-readability-text-large { + font-size: 0.9375rem; + line-height: 1.7; } .chat-message-inline-body { @@ -288,7 +307,6 @@ label:has(> select#reasoning-effort) select { min-width: 0; overflow-wrap: anywhere; word-break: break-word; - text-align: start; } .chat-markdown > :first-child { @@ -360,8 +378,9 @@ label:has(> select#reasoning-effort) select { background: var(--muted); padding: 0.1rem 0.35rem; color: var(--foreground); - font-size: 0.75rem; + font-size: 0.9em; direction: ltr; + unicode-bidi: isolate; } .chat-markdown pre { @@ -378,13 +397,14 @@ label:has(> select#reasoning-effort) select { background: transparent; padding: 0; line-height: 1.5; - font-size: 0.875rem; + font-size: 0.95em; } .chat-markdown .chat-markdown-codeblock { position: relative; margin: 0.65rem 0; direction: ltr; + unicode-bidi: isolate; } .chat-markdown .chat-markdown-codeblock pre { diff --git a/apps/web/src/lib/chatReadability.test.ts b/apps/web/src/lib/chatReadability.test.ts new file mode 100644 index 00000000..8146e95b --- /dev/null +++ b/apps/web/src/lib/chatReadability.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveChatReadabilityClassName } from "./chatReadability"; + +describe("resolveChatReadabilityClassName", () => { + it("keeps auto typography free of script-specific font overrides", () => { + const className = resolveChatReadabilityClassName({ + direction: "rtl", + fontFamily: "auto", + textSize: "default", + }); + + expect(className).toContain("chat-readability-surface"); + expect(className).toContain("chat-readability-direction-rtl"); + expect(className).toContain("chat-readability-text-default"); + expect(className).not.toContain("chat-readability-font-noto-sans-multiscript"); + }); + + it("applies explicit font and size classes when selected", () => { + const className = resolveChatReadabilityClassName({ + direction: "ltr", + fontFamily: "noto-sans", + textSize: "large", + }); + + expect(className).toContain("chat-readability-direction-ltr"); + expect(className).toContain("chat-readability-font-noto-sans"); + expect(className).toContain("chat-readability-text-large"); + }); + + it("supports the bundled multiscript font stack", () => { + const className = resolveChatReadabilityClassName({ + direction: "auto", + fontFamily: "noto-sans-multiscript", + textSize: "small", + }); + + expect(className).toContain("chat-readability-direction-auto"); + expect(className).toContain("chat-readability-font-noto-sans-multiscript"); + expect(className).toContain("chat-readability-text-small"); + }); +}); diff --git a/apps/web/src/lib/chatReadability.ts b/apps/web/src/lib/chatReadability.ts new file mode 100644 index 00000000..5714b8a0 --- /dev/null +++ b/apps/web/src/lib/chatReadability.ts @@ -0,0 +1,34 @@ +import type { ChatFontFamily, ChatTextSize } from "@t3tools/contracts/settings"; +import type { TextDirection } from "./textDirection"; +import { cn } from "./utils"; + +const CHAT_DIRECTION_CLASS = { + rtl: "chat-readability-direction-rtl", + ltr: "chat-readability-direction-ltr", + auto: "chat-readability-direction-auto", +} as const satisfies Record; + +const CHAT_FONT_CLASS_BY_FAMILY = { + "dm-sans": "chat-readability-font-dm-sans", + "noto-sans": "chat-readability-font-noto-sans", + "noto-sans-multiscript": "chat-readability-font-noto-sans-multiscript", +} as const satisfies Record, string>; + +const CHAT_TEXT_SIZE_CLASS = { + small: "chat-readability-text-small", + default: "chat-readability-text-default", + large: "chat-readability-text-large", +} as const satisfies Record; + +export function resolveChatReadabilityClassName(options: { + direction: TextDirection; + fontFamily: ChatFontFamily; + textSize: ChatTextSize; +}) { + return cn( + "chat-readability-surface", + CHAT_DIRECTION_CLASS[options.direction], + CHAT_TEXT_SIZE_CLASS[options.textSize], + options.fontFamily !== "auto" && CHAT_FONT_CLASS_BY_FAMILY[options.fontFamily], + ); +} diff --git a/apps/web/src/lib/chatTypography.ts b/apps/web/src/lib/chatTypography.ts deleted file mode 100644 index bb21ecb4..00000000 --- a/apps/web/src/lib/chatTypography.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { ChatFontFamily } from "@t3tools/contracts/settings"; -import type { TextDirection } from "./textDirection"; -import { cn } from "./utils"; - -type ChatTypographyVariant = "chat" | "composer"; - -const CHAT_DIRECTION_CLASS_BY_VARIANT = { - chat: { - rtl: "chat-direction-rtl", - ltr: "chat-direction-ltr", - auto: "chat-direction-auto", - }, - composer: { - rtl: "chat-composer-direction-rtl", - ltr: "chat-composer-direction-ltr", - auto: "chat-composer-direction-auto", - }, -} as const satisfies Record>; - -const CHAT_FONT_CLASS_BY_FAMILY = { - "dm-sans": "chat-font-dm-sans", - "noto-sans-arabic": "chat-font-noto-sans-arabic", -} as const satisfies Record, string>; - -export function resolveChatTypographyClassName(options: { - direction: TextDirection; - fontFamily: ChatFontFamily; - variant?: ChatTypographyVariant; -}) { - const variant = options.variant ?? "chat"; - return cn( - CHAT_DIRECTION_CLASS_BY_VARIANT[variant][options.direction], - options.fontFamily !== "auto" && CHAT_FONT_CLASS_BY_FAMILY[options.fontFamily], - ); -} diff --git a/apps/web/src/lib/textDirection.test.ts b/apps/web/src/lib/textDirection.test.ts index c8510aaf..a07af9ec 100644 --- a/apps/web/src/lib/textDirection.test.ts +++ b/apps/web/src/lib/textDirection.test.ts @@ -8,6 +8,11 @@ describe("resolveTextDirection", () => { expect(isRtlText("تحليل المشروع")).toBe(true); }); + it("detects Hebrew text as rtl without assuming Arabic", () => { + expect(resolveTextDirection("שלום עולם")).toBe("rtl"); + expect(isRtlText("בדיקת ממשק")).toBe(true); + }); + it("treats Latin text as ltr", () => { expect(resolveTextDirection("Review the latest diff")).toBe("ltr"); expect(isRtlText("Run tests")).toBe(false); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fc58d31f..b28dd5da 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -6,9 +6,21 @@ import { createHashHistory, createBrowserHistory } from "@tanstack/react-router" import "@fontsource/dm-sans/400.css"; import "@fontsource/dm-sans/500.css"; import "@fontsource/dm-sans/700.css"; +import "@fontsource/noto-sans/400.css"; +import "@fontsource/noto-sans/500.css"; +import "@fontsource/noto-sans/700.css"; import "@fontsource/noto-sans-arabic/400.css"; import "@fontsource/noto-sans-arabic/500.css"; import "@fontsource/noto-sans-arabic/700.css"; +import "@fontsource/noto-sans-devanagari/400.css"; +import "@fontsource/noto-sans-devanagari/500.css"; +import "@fontsource/noto-sans-devanagari/700.css"; +import "@fontsource/noto-sans-hebrew/400.css"; +import "@fontsource/noto-sans-hebrew/500.css"; +import "@fontsource/noto-sans-hebrew/700.css"; +import "@fontsource/noto-sans-thai/400.css"; +import "@fontsource/noto-sans-thai/500.css"; +import "@fontsource/noto-sans-thai/700.css"; import "@xterm/xterm/css/xterm.css"; import "./index.css"; diff --git a/bun.lock b/bun.lock index e70568ae..332ddc19 100644 --- a/bun.lock +++ b/bun.lock @@ -70,7 +70,11 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource/dm-sans": "^5.2.8", + "@fontsource/noto-sans": "^5.2.10", "@fontsource/noto-sans-arabic": "^5.2.10", + "@fontsource/noto-sans-devanagari": "^5.2.8", + "@fontsource/noto-sans-hebrew": "^5.2.8", + "@fontsource/noto-sans-thai": "^5.2.8", "@formkit/auto-animate": "^0.9.0", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", @@ -336,8 +340,16 @@ "@fontsource/dm-sans": ["@fontsource/dm-sans@5.2.8", "", {}, "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA=="], + "@fontsource/noto-sans": ["@fontsource/noto-sans@5.2.10", "", {}, "sha512-J58RVfS/C0Z2VBF+PoU260Tx8cdRGYuS+e3yQe4hYaIYDl0sEVn5CzlLo5zVRvQD0HaIUTV8AZMfqR7rtdEpqQ=="], + "@fontsource/noto-sans-arabic": ["@fontsource/noto-sans-arabic@5.2.10", "", {}, "sha512-Hgj3NwQJ0NquIADW3hFboFjsts4UvEOJQ8tOX0bhQZgpNKHsMawjv/1SZnq0QvRbP6efHTZM/A1k7tdy9S6sbA=="], + "@fontsource/noto-sans-devanagari": ["@fontsource/noto-sans-devanagari@5.2.8", "", {}, "sha512-UyCfyCEX2+QVUDQWb9y2MRTg1O3bTcXixzD2xQfapFOtw4hpH1rfDIbTXulZ4SUEsbB/wWeyyEL9b93Nmwd6Gg=="], + + "@fontsource/noto-sans-hebrew": ["@fontsource/noto-sans-hebrew@5.2.8", "", {}, "sha512-FN/GDpE709JQN5f7vmqlbIwz2/vAM6ZnXqRsw47Piq731hvti55V/FLbyU2RWUvlis16ezPf0r+zKTjfdeXF/g=="], + + "@fontsource/noto-sans-thai": ["@fontsource/noto-sans-thai@5.2.8", "", {}, "sha512-HUQAxOzclod9PXQx4uMoGRAsor+64snW91QuBg39jT9lbsp7jJx1i3jAtHPJ7mQxHEHnhRjNp54fb8GqPPC6zg=="], + "@formkit/auto-animate": ["@formkit/auto-animate@0.9.0", "", {}, "sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 4c1f7318..14bd3a35 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -29,12 +29,22 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; -export const ChatFontFamily = Schema.Literals(["auto", "dm-sans", "noto-sans-arabic"]); +export const ChatFontFamily = Schema.Literals([ + "auto", + "dm-sans", + "noto-sans", + "noto-sans-multiscript", +]); export type ChatFontFamily = typeof ChatFontFamily.Type; export const DEFAULT_CHAT_FONT_FAMILY: ChatFontFamily = "auto"; +export const ChatTextSize = Schema.Literals(["small", "default", "large"]); +export type ChatTextSize = typeof ChatTextSize.Type; +export const DEFAULT_CHAT_TEXT_SIZE: ChatTextSize = "default"; + export const ClientSettingsSchema = Schema.Struct({ chatFontFamily: ChatFontFamily.pipe(Schema.withDecodingDefault(() => DEFAULT_CHAT_FONT_FAMILY)), + chatTextSize: ChatTextSize.pipe(Schema.withDecodingDefault(() => DEFAULT_CHAT_TEXT_SIZE)), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(() => false)), From faaa3bfd06d4c38d432b7f6807d40fdeb521fdb7 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:38:54 -0400 Subject: [PATCH 3/4] fix: preserve auto chat fonts Keep terminal-context user bubbles out of the monospace stack when their visible text has auto direction. Add a regression test for an auto-direction user message with terminal context metadata so the reviewed path stays covered. --- .../components/chat/MessagesTimeline.test.tsx | 57 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 2 +- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e6e85a07..7870a09a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -143,4 +143,61 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Context compacted"); expect(markup).toContain("Work log"); }); + + it("does not force auto-direction user bubbles into monospace", () => { + const markup = renderToStaticMarkup( + ", + "- Terminal 1 lines 1-2:", + " 1 | bun install", + " 2 | bun run dev", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]} + completionDividerBeforeEntryId={null} + completionSummary={null} + turnDiffSummaryByAssistantMessageId={new Map()} + nowIso="2026-03-17T19:12:30.000Z" + expandedWorkGroups={{}} + onToggleWorkGroup={() => {}} + changedFilesExpandedByTurnId={{}} + onSetChangedFilesExpanded={() => {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain('dir="auto"'); + expect(markup).toContain("chat-readability-direction-auto"); + expect(markup).not.toContain( + "chat-message-inline-body whitespace-pre-wrap text-foreground font-mono", + ); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d18431d..c1d7608c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -733,7 +733,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { const textDirection = resolveTextDirection(props.text); const bodyClassName = cn( "chat-message-inline-body whitespace-pre-wrap text-foreground", - textDirection !== "rtl" && "font-mono", + textDirection === "ltr" && "font-mono", resolveChatReadabilityClassName({ direction: textDirection, fontFamily: chatFontFamily, From d8ceccc5a8cb0a0c0ef2486dd65b2b83f4354cbd Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:00:45 -0400 Subject: [PATCH 4/4] fix(chat): preserve monospace terminal context --- .../components/chat/MessagesTimeline.test.tsx | 28 +++++++++++++++---- .../src/components/chat/MessagesTimeline.tsx | 8 +++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 7870a09a..8d33d933 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,8 +1,12 @@ import { MessageId } from "@t3tools/contracts"; +import { ClientSettingsSchema, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { renderToStaticMarkup } from "react-dom/server"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { removeLocalStorageItem, setLocalStorageItem } from "../../hooks/useLocalStorage"; import { MessagesTimeline } from "./MessagesTimeline"; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; + function matchMedia() { return { matches: false, @@ -43,6 +47,10 @@ beforeAll(() => { }); }); +beforeEach(() => { + removeLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY); +}); + describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", () => { const markup = renderToStaticMarkup( @@ -144,7 +152,17 @@ describe("MessagesTimeline", () => { expect(markup).toContain("Work log"); }); - it("does not force auto-direction user bubbles into monospace", () => { + it("keeps terminal context user bubbles monospace with explicit chat fonts", () => { + setLocalStorageItem( + CLIENT_SETTINGS_STORAGE_KEY, + { + ...DEFAULT_CLIENT_SETTINGS, + chatFontFamily: "noto-sans", + chatTextSize: "default", + }, + ClientSettingsSchema, + ); + const markup = renderToStaticMarkup( { expect(markup).toContain('dir="auto"'); expect(markup).toContain("chat-readability-direction-auto"); - expect(markup).not.toContain( - "chat-message-inline-body whitespace-pre-wrap text-foreground font-mono", - ); + expect(markup).toContain("chat-message-inline-body"); + expect(markup).toContain("font-mono"); + expect(markup).not.toContain("chat-readability-font-noto-sans"); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c1d7608c..b3af346e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -728,15 +728,15 @@ const UserMessageBody = memo(function UserMessageBody(props: { text: string; terminalContexts: ParsedTerminalContextEntry[]; }) { - const chatFontFamily = useSettings((settings) => settings.chatFontFamily); const chatTextSize = useSettings((settings) => settings.chatTextSize); const textDirection = resolveTextDirection(props.text); const bodyClassName = cn( - "chat-message-inline-body whitespace-pre-wrap text-foreground", - textDirection === "ltr" && "font-mono", + "chat-message-inline-body whitespace-pre-wrap font-mono text-foreground", resolveChatReadabilityClassName({ direction: textDirection, - fontFamily: chatFontFamily, + // Terminal-context bubbles stay monospaced even when chat copy uses a + // proportional font override. + fontFamily: "auto", textSize: chatTextSize, }), );