Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 17 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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);
}, []);
Expand Down Expand Up @@ -310,7 +316,17 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
);

return (
<div className="chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80">
<div
dir={textDirection}
lang={textDirection === "rtl" ? "ar" : undefined}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lang is hardcoded to "ar" for any RTL text, but direction != language (e.g., Hebrew is RTL). This can degrade screen reader pronunciation and font shaping. Consider removing lang here or computing it from Arabic-script detection separately from resolveTextDirection.

Suggested change
lang={textDirection === "rtl" ? "ar" : undefined}

Copilot uses AI. Check for mistakes.
className={cn(
"chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/80",
resolveChatTypographyClassName({
direction: textDirection,
fontFamily: chatFontFamily,
}),
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
Expand Down
26 changes: 25 additions & 1 deletion apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ import {
} from "~/lib/terminalContext";
import { cn } from "~/lib/utils";
import { basenameOfPath, getVscodeIconUrlForEntry, inferEntryKindFromPath } from "~/vscode-icons";
import { useSettings } from "../hooks/useSettings";
import { resolveChatTypographyClassName } from "~/lib/chatTypography";
import { resolveTextDirection } from "~/lib/textDirection";
import {
COMPOSER_INLINE_CHIP_CLASS_NAME,
COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
Expand Down Expand Up @@ -894,6 +897,17 @@ function ComposerPromptEditorInner({
const [editor] = useLexicalComposerContext();
const onChangeRef = useRef(onChange);
const initialCursor = clampCollapsedComposerCursor(value, cursor);
const chatFontFamily = useSettings((settings) => 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({
Expand Down Expand Up @@ -1092,8 +1106,11 @@ function ComposerPromptEditorInner({
<PlainTextPlugin
contentEditable={
<ContentEditable
dir={composerDirection}
lang={composerDirection === "rtl" ? "ar" : undefined}
className={cn(
Comment on lines +1109 to 1111
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lang is set to "ar" whenever the composer direction is RTL. Since RTL direction can come from non-Arabic scripts, this can mislabel the user’s input for assistive tech. Prefer omitting lang or setting it only when Arabic script is detected.

Copilot uses AI. Check for mistakes.
"block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-[14px] leading-relaxed text-foreground focus:outline-none",
composerTypographyClassName,
className,
)}
data-testid="composer-editor"
Expand All @@ -1104,7 +1121,14 @@ function ComposerPromptEditorInner({
}
placeholder={
terminalContexts.length > 0 ? null : (
<div className="pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35">
<div
dir={composerDirection}
lang={composerDirection === "rtl" ? "ar" : undefined}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Placeholder uses the same RTL→lang="ar" mapping, which can mislabel the placeholder text language for assistive technologies. Consider removing lang here or deriving it from Arabic-script detection instead of direction.

Suggested change
lang={composerDirection === "rtl" ? "ar" : undefined}

Copilot uses AI. Check for mistakes.
className={cn(
"pointer-events-none absolute inset-0 text-[14px] leading-relaxed text-muted-foreground/35",
composerTypographyClassName,
)}
>
{placeholder}
</div>
)
Expand Down
26 changes: 24 additions & 2 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -752,7 +766,11 @@ const UserMessageBody = memo(function UserMessageBody(props: {
}

return (
<div className="wrap-break-word whitespace-pre-wrap font-mono text-sm leading-relaxed text-foreground">
<div
dir={textDirection}
lang={textDirection === "rtl" ? "ar" : undefined}
className={bodyClassName}
Comment on lines +769 to +772
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lang is set to "ar" whenever the resolved direction is RTL, but resolveTextDirection treats multiple scripts as RTL (e.g., Hebrew). This can mislabel content language for screen readers and text shaping. Consider either omitting lang entirely here, or setting it based on an Arabic-script detection (separate regex) rather than direction alone.

Copilot uses AI. Check for mistakes.
>
{inlineNodes}
</div>
);
Expand Down Expand Up @@ -780,7 +798,11 @@ const UserMessageBody = memo(function UserMessageBody(props: {
}

return (
<div className="wrap-break-word whitespace-pre-wrap font-mono text-sm leading-relaxed text-foreground">
<div
dir={textDirection}
lang={textDirection === "rtl" ? "ar" : undefined}
className={bodyClassName}
Comment on lines +801 to +804
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: lang is derived from RTL direction and hardcoded to "ar", which can mislabel RTL-but-non-Arabic content. Prefer omitting lang or deriving it from Arabic-script detection instead of direction.

Copilot uses AI. Check for mistakes.
>
{inlineNodes}
</div>
);
Expand Down
50 changes: 49 additions & 1 deletion apps/web/src/components/settings/SettingsAppearancePanel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<ChatFontFamily, string>;

export function SettingsAppearancePanel() {
const { theme, setTheme } = useTheme();
const settings = useSettings();
Expand Down Expand Up @@ -64,6 +70,48 @@ export function SettingsAppearancePanel() {
}
/>

<SettingsRow
title="Chat font"
description="Auto keeps Arabic text on an Arabic-optimized font while preserving the default app styling elsewhere."
resetAction={
settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily ? (
<SettingResetButton
label="chat font"
onClick={() =>
updateSettings({
chatFontFamily: DEFAULT_UNIFIED_SETTINGS.chatFontFamily,
})
}
/>
) : null
}
control={
<Select
value={settings.chatFontFamily}
onValueChange={(value) => {
if (value === "auto" || value === "dm-sans" || value === "noto-sans-arabic") {
updateSettings({ chatFontFamily: value });
}
}}
>
<SelectTrigger className="w-full sm:w-52" aria-label="Chat font family">
<SelectValue>{CHAT_FONT_LABELS[settings.chatFontFamily]}</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
<SelectItem hideIndicator value="auto">
{CHAT_FONT_LABELS.auto}
</SelectItem>
<SelectItem hideIndicator value="dm-sans">
{CHAT_FONT_LABELS["dm-sans"]}
</SelectItem>
<SelectItem hideIndicator value="noto-sans-arabic">
{CHAT_FONT_LABELS["noto-sans-arabic"]}
</SelectItem>
</SelectPopup>
</Select>
}
/>

<SettingsRow
title="Time format"
description="System default follows your browser or OS clock preference."
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/settings/SettingsGeneralPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function useSettingsRestore(onRestored?: () => void) {
const changedSettingLabels = useMemo(
() => [
...(theme !== "system" ? ["Theme"] : []),
...(settings.chatFontFamily !== DEFAULT_UNIFIED_SETTINGS.chatFontFamily ? ["Chat font"] : []),
...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat
? ["Time format"]
: []),
Expand Down Expand Up @@ -87,6 +88,7 @@ export function useSettingsRestore(onRestored?: () => void) {
isGitWritingModelDirty,
isPromptEnhanceModelDirty,
settings.askModelSelection,
settings.chatFontFamily,
settings.codeModelSelection,
settings.commitMessageStyle,
settings.confirmThreadArchive,
Expand Down
4 changes: 1 addition & 3 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,7 @@ function splitPatch(patch: SettingsUpdatePatch): {
* only re-render when the slice they care about changes.
*/

export function useSettings<T extends UnifiedSettings = UnifiedSettings>(
selector?: (s: UnifiedSettings) => T,
): T {
export function useSettings<T = UnifiedSettings>(selector?: (s: UnifiedSettings) => T): T {
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new generic signature allows useSettings<SomeType>() to be called without a selector, which becomes an unsound cast at runtime. To keep the improved selector typing while preventing misuse, consider using overloads (no selector → UnifiedSettings, selector → inferred return type) instead of an unconstrained generic default.

Copilot uses AI. Check for mistakes.
const serverSettings = useServerSettings();
const [clientSettings] = useLocalStorage(
CLIENT_SETTINGS_STORAGE_KEY,
Expand Down
61 changes: 48 additions & 13 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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) */
Expand Down
Loading
Loading