Skip to content
Merged
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
5 changes: 5 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +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",
Expand Down
19 changes: 18 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 { resolveChatReadabilityClassName } from "~/lib/chatReadability";
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,10 @@ 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) => {
return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
}, []);
Expand Down Expand Up @@ -310,7 +317,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}
className={cn(
"chat-markdown w-full min-w-0 text-foreground/80",
resolveChatReadabilityClassName({
direction: textDirection,
fontFamily: chatFontFamily,
textSize: chatTextSize,
}),
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
Expand Down
27 changes: 25 additions & 2 deletions 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 { resolveChatReadabilityClassName } from "~/lib/chatReadability";
import { resolveTextDirection } from "~/lib/textDirection";
import {
COMPOSER_INLINE_CHIP_CLASS_NAME,
COMPOSER_INLINE_CHIP_ICON_CLASS_NAME,
Expand Down Expand Up @@ -894,6 +897,18 @@ function ComposerPromptEditorInner({
const [editor] = useLexicalComposerContext();
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 composerReadabilityClassName = useMemo(
() =>
resolveChatReadabilityClassName({
direction: composerDirection,
fontFamily: chatFontFamily,
textSize: chatTextSize,
}),
[chatFontFamily, chatTextSize, composerDirection],
);
const terminalContextsSignature = terminalContextSignature(terminalContexts);
const terminalContextsSignatureRef = useRef(terminalContextsSignature);
const snapshotRef = useRef({
Expand Down Expand Up @@ -1092,8 +1107,10 @@ function ComposerPromptEditorInner({
<PlainTextPlugin
contentEditable={
<ContentEditable
dir={composerDirection}
className={cn(
"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",
"block max-h-[200px] min-h-17.5 w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent text-foreground focus:outline-none",
composerReadabilityClassName,
className,
)}
data-testid="composer-editor"
Expand All @@ -1104,7 +1121,13 @@ 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}
className={cn(
"pointer-events-none absolute inset-0 text-muted-foreground/35",
composerReadabilityClassName,
)}
>
{placeholder}
</div>
)
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -133,6 +133,7 @@ export function estimateMessagesTimelineRowHeight(
row: MessagesTimelineRow,
input: {
timelineWidthPx: number | null;
chatTextSize?: ChatTextSize;
expandedWorkGroups?: Readonly<Record<string, boolean>>;
turnDiffSummaryByAssistantMessageId?: ReadonlyMap<MessageId, TurnDiffSummary>;
},
Expand All @@ -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) {
Expand Down
77 changes: 76 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -143,4 +151,71 @@ describe("MessagesTimeline", () => {
expect(markup).toContain("Context compacted");
expect(markup).toContain("Work log");
});

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(
<MessagesTimeline
hasMessages
isWorking={false}
activeTurnInProgress={false}
activeTurnStartedAt={null}
scrollContainer={null}
timelineEntries={[
{
id: "entry-1",
kind: "message",
createdAt: "2026-03-17T19:12:28.000Z",
message: {
id: MessageId.makeUnsafe("message-auto-direction"),
role: "user",
text: [
"สวัสดีจากบทสนทนา",
"",
"<terminal_context>",
"- Terminal 1 lines 1-2:",
" 1 | bun install",
" 2 | bun run dev",
"</terminal_context>",
].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).toContain("chat-message-inline-body");
expect(markup).toContain("font-mono");
expect(markup).not.toContain("chat-readability-font-noto-sans");
});
});
43 changes: 38 additions & 5 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 { resolveChatReadabilityClassName } from "~/lib/chatReadability";
import { resolveTextDirection } from "~/lib/textDirection";

const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;

Expand Down Expand Up @@ -95,6 +98,7 @@ interface MessagesTimelineProps {
onReviewImplementAll?: (findings: ReviewFinding[]) => void;
onReviewDismissAll?: () => void;
onVirtualizerSnapshot?: (snapshot: {
measurementScopeKey: string;
totalSize: number;
measurements: ReadonlyArray<{
id: string;
Expand Down Expand Up @@ -140,6 +144,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const timelineRootRef = useRef<HTMLDivElement | null>(null);
const pendingMeasureFrameRef = useRef<number | null>(null);
const [timelineWidthPx, setTimelineWidthPx] = useState<number | null>(null);
const chatFontFamily = useSettings((settings) => settings.chatFontFamily);
const chatTextSize = useSettings((settings) => settings.chatTextSize);
const typographyMeasurementScopeKey = `${chatFontFamily}:${chatTextSize}`;

useLayoutEffect(() => {
const timelineRoot = timelineRootRef.current;
Expand Down Expand Up @@ -224,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,
Expand All @@ -239,6 +248,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const row = rows[index];
if (!row) return 96;
return estimateMessagesTimelineRowHeight(row, {
chatTextSize,
expandedWorkGroups,
timelineWidthPx,
turnDiffSummaryByAssistantMessageId,
Expand All @@ -259,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;
Expand All @@ -280,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;
Expand Down Expand Up @@ -313,6 +326,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
return;
}
onVirtualizerSnapshot({
measurementScopeKey: typographyMeasurementScopeKey,
totalSize: rowVirtualizer.getTotalSize(),
measurements: rowVirtualizer.measurementsCache
.slice(0, virtualizedRowCount)
Expand All @@ -333,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);
Expand Down Expand Up @@ -708,6 +728,19 @@ const UserMessageBody = memo(function UserMessageBody(props: {
text: string;
terminalContexts: ParsedTerminalContextEntry[];
}) {
const chatTextSize = useSettings((settings) => settings.chatTextSize);
const textDirection = resolveTextDirection(props.text);
const bodyClassName = cn(
"chat-message-inline-body whitespace-pre-wrap font-mono text-foreground",
resolveChatReadabilityClassName({
direction: textDirection,
// Terminal-context bubbles stay monospaced even when chat copy uses a
// proportional font override.
fontFamily: "auto",
textSize: chatTextSize,
}),
);

if (props.terminalContexts.length > 0) {
const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels(
props.text,
Expand Down Expand Up @@ -752,7 +785,7 @@ 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} className={bodyClassName}>
{inlineNodes}
</div>
);
Expand Down Expand Up @@ -780,7 +813,7 @@ 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} className={bodyClassName}>
{inlineNodes}
</div>
);
Expand Down
Loading
Loading