);
@@ -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,
}),
);