Skip to content
Closed
61 changes: 60 additions & 1 deletion apps/web/src/components/ChatMarkdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";

import { orderedListGutterStyle } from "./ChatMarkdown";
import ChatMarkdown, { orderedListGutterStyle } from "./ChatMarkdown";

describe("orderedListGutterStyle", () => {
it("leaves the default gutter alone for single-digit lists", () => {
Expand Down Expand Up @@ -34,3 +35,61 @@ describe("orderedListGutterStyle", () => {
expect(orderedListGutterStyle(0, undefined)).toBeUndefined();
});
});

describe("chat markdown text direction", () => {
function render(text: string) {
return renderToStaticMarkup(<ChatMarkdown text={text} cwd="/repo" />);
}

it("lets each block pick its own direction from its own text", () => {
const html = render("English first.\n\nمرحبا بالعالم.");
expect(html).toContain('<p dir="auto">English first.</p>');
expect(html).toContain('<p dir="auto">مرحبا بالعالم.</p>');
});

it("marks headings, lists, and quotes so their markers follow the text", () => {
const html = render("# عنوان\n\n- عنصر\n\n> اقتباس");
expect(html).toContain('<h1 dir="auto">');
expect(html).toContain('<ul dir="auto">');
expect(html).toContain('<blockquote dir="auto">');
});

it("marks only the outermost block, so a container still sees its own text", () => {
// A nested `dir` would be skipped when the browser resolves the outer
// `dir="auto"`, leaving the list LTR and its bullets in the wrong gutter.
const html = render("- عنصر\n\n> اقتباس");
expect(html).toContain("<li>");
expect(html).not.toContain("<li dir=");
expect(html).not.toContain('<blockquote dir="auto">\n<p dir="auto">');
});

it("pins code left-to-right so an Arabic comment cannot reorder a snippet", () => {
const html = render("`git status` وأيضا\n\n```sh\n# تعليق\ngit status\n```");
// The paragraph around it still reads right-to-left; only the code opts out.
expect(html).toContain('<p dir="auto">');
expect(html).toContain('<code data-inline-code="" dir="ltr">git status</code>');
expect(html).toContain('<div dir="ltr" class="chat-markdown-codeblock');
});

it("gives a GitHub alert's body its own direction under LTR callout chrome", () => {
// The alert renderer builds its own element, so the blockquote cannot be the
// marked block — the body paragraphs have to carry the direction instead.
const html = render("> [!NOTE]\n> مرحبا بالعالم.");
expect(html).toContain('<p dir="auto">مرحبا بالعالم.</p>');
expect(html).not.toContain("<blockquote");
});

it("pins a file-link chip left-to-right even inside right-to-left prose", () => {
// The `code` renderer swaps the chip in for the `<code dir="ltr">` it
// replaces, so a path in an Arabic sentence keeps its own reading order.
const html = render("عدّل `src/main.ts` من فضلك.");
expect(html).toContain('<a dir="ltr"');
});

it("keeps table columns in source order while cells read their own direction", () => {
const html = render("| اسم | value |\n| --- | --- |\n| قيمة | 1 |");
expect(html).toContain('<table dir="ltr">');
expect(html).toContain('<th dir="auto">');
expect(html).toContain('<td dir="auto">');
});
});
79 changes: 78 additions & 1 deletion apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [
remarkNormalizeListItemIndentation,
remarkPreserveCodeMeta,
remarkTagInlineCode,
remarkTextDirection,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
Expand All @@ -204,6 +205,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [
remarkBreaks,
remarkPreserveCodeMeta,
remarkTagInlineCode,
remarkTextDirection,
] satisfies NonNullable<ReactMarkdownOptions["remarkPlugins"]>;

const CHAT_MARKDOWN_REHYPE_PLUGINS = [
Expand Down Expand Up @@ -340,6 +342,74 @@ function remarkTagInlineCode() {
};
}

/**
* Message prose belongs to whoever wrote it, so its direction is a property of
* the text and not of the app: `dir="auto"` makes the browser read each block's
* base direction off that block's own first strong character, which is what
* puts an Arabic sentence's trailing punctuation and its list markers on the
* right side without touching the English block above it.
*
* Code and tables opt out and stay LTR. Their shape is not prose — identifiers,
* paths, and column order read the same in every locale, and letting an Arabic
* comment flip a snippet would misreport what the agent actually wrote.
*/
const AUTO_DIRECTION_NODE_TYPES = new Set([
"blockquote",
Comment thread
AsimNet marked this conversation as resolved.
"heading",
"list",
"paragraph",
"tableCell",
]);
const LTR_DIRECTION_NODE_TYPES = new Set(["code", "inlineCode", "table"]);
Comment thread
AsimNet marked this conversation as resolved.

function setDirection(node: MarkdownAstNode, dir: "auto" | "ltr") {
node.data = {
...node.data,
hProperties: {
...node.data?.hProperties,
dir,
},
};
}

function remarkTextDirection() {
return (tree: MarkdownAstNode) => {
// `dir="auto"` reads the first strong character of an element's *own* text
// and skips any descendant that carries its own `dir`. So only the outermost
// block of a run gets marked: marking a list and its items both would leave
// the list itself with no text to judge, fall back to LTR, and paint the
// bullets of an RTL item into a gutter that is no longer on that side.
//
// The cost is that one list reads in one direction. A list that mixes an
// Arabic item with an English one takes the direction of its first item,
// which is the trade for markers that stay next to the text they label.
const visit = (node: MarkdownAstNode, insideAutoBlock: boolean) => {
const type = node.type ?? "";
if (LTR_DIRECTION_NODE_TYPES.has(type)) {
setDirection(node, "ltr");
// A pinned table is not an `auto` ancestor, so its cells are free to
// pick their own direction while the column order stays put.
node.children?.forEach((child) => visit(child, false));
return;
}

// A GitHub alert is rendered as a titled callout rather than a quote, and
// its own renderer builds that chrome from scratch. Claiming the block
// here would strand its body: the `dir` never reaches the callout, and the
// paragraphs inside it would have been skipped as already-covered.
const isAlertBlockquote = type === "blockquote" && node.data?.hProperties?.dataAlert != null;
const isAutoBlock =
!insideAutoBlock && !isAlertBlockquote && AUTO_DIRECTION_NODE_TYPES.has(type);
if (isAutoBlock) {
setDirection(node, "auto");
}
node.children?.forEach((child) => visit(child, insideAutoBlock || isAutoBlock));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium components/ChatMarkdown.tsx:396

Mixed-direction lists render every item with the direction inferred from the first item, so later items get incorrect base direction for their punctuation and inline content. Because insideAutoBlock is propagated through list descendants, each item's block is prevented from receiving its own dir="auto"; reset that state when traversing list children.

Suggested change
node.children?.forEach((child) => visit(child, insideAutoBlock || isAutoBlock));
node.children?.forEach((child) => visit(child, type === "list" ? false : insideAutoBlock || isAutoBlock));
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/ChatMarkdown.tsx around line 396:

Mixed-direction lists render every item with the direction inferred from the first item, so later items get incorrect base direction for their punctuation and inline content. Because `insideAutoBlock` is propagated through `list` descendants, each item's block is prevented from receiving its own `dir="auto"`; reset that state when traversing list children.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Not taking this one, and I want to show my work because the suggested patch doesn't do what it looks like it does.

Resetting the flag for list children can't put dir on the items themselves — listItem isn't in AUTO_DIRECTION_NODE_TYPES, precisely because <li dir="auto"> inside <ul dir="auto"> is the bug the outermost-block rule exists to prevent (the ul then has no text of its own to judge, falls back to LTR, and paints an RTL item's bullet into a gutter that moved to the other side — I hit this in the browser, not in a test).

So the reset would only reach the paragraph inside each item, and mdast-util-to-hast unwraps that paragraph in tight lists. Rendered output today:

tight:  <ul dir="auto"><li>عنصر عربي.</li><li>An English item.</li></ul>
loose:  <ul dir="auto"><li><p>عنصر عربي.</p></li><li><p>An English item.</p></li></ul>

The suggestion changes only the loose case. That would make - a\n- b and - a\n\n- b resolve direction differently — a rule that depends on blank lines between items is harder to predict than one list reading one way.

A mixed list therefore takes its first item's direction. The minority item still renders as a correct LTR run inside it; what it loses is its alignment and the side its full stop lands on. I've written that trade-off into the comment above the plugin so the next person doesn't have to rediscover it. If you'd rather have per-item direction, it needs the marker rendered inside the item (custom ::before markers, dropping list-style-position: outside), which is a bigger change to list styling than this PR should carry.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

};

visit(tree, false);
};
}

function nodeToPlainText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") {
return String(node);
Expand Down Expand Up @@ -671,6 +741,9 @@ function MarkdownCodeBlock({

return (
<div
// The fence's own chrome, not just its code: a title bar and copy/wrap
// controls that read left-to-right regardless of the prose around them.
dir="ltr"
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
Comment on lines +744 to +746

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fence pins its whole wrapper here so the chrome stays LTR, but the other pinned widget only gets the attribute on the inner element: LTR_DIRECTION_NODE_TYPES puts dir="ltr" on the <table>, while MarkdownTable's own .chat-markdown-table-container (ChatMarkdown.tsx:476) and its ScrollArea carry no direction. A table nested in a block this PR now resolves RTL — a quote or a list item whose text is Arabic — leaves that wrapper inheriting RTL, so the horizontal scroller opens on the table's last column instead of its first and the expand/copy toolbar swaps ends, while a code fence in the same position does not. The PR's stated rule is that tables stay LTR, and today only the element does, not the widget.

Suggest pinning the container the same way the fence is; the cells keep their own dir="auto", so cell alignment is unchanged:

     <div
       ref={containerRef}
+      dir="ltr"
       className="chat-markdown-table-container"

Posted via Macroscope — UI Consistency

className="chat-markdown-codeblock my-[0.65rem] overflow-hidden rounded-[var(--radius)] border border-border/70 bg-secondary leading-snug dark:border-transparent dark:bg-input/32"
data-language={language}
data-wrap={wrapped ? "true" : "false"}
Expand Down Expand Up @@ -1301,6 +1374,10 @@ const MarkdownFileLink = memo(function MarkdownFileLink({
<TooltipTrigger
render={
<a
// A path is an identifier, so it reads left-to-right wherever the
// prose around it points. The `code` renderer swaps this chip in for
// the `<code dir="ltr">` it replaces, so the pin has to live here too.
dir="ltr"
href={href}
className={cn(CHAT_FILE_TAG_CHIP_CLASS_NAME, MARKDOWN_FILE_LINK_CLASS_NAME, className)}
data-markdown-copy={copyMarkdown}
Expand Down Expand Up @@ -1570,7 +1647,7 @@ function ChatMarkdown({
// Not a <blockquote>: the stylesheet mutes those, and an alert's body is ordinary
// text under a colored title — which is how the host renders it.
return (
<div role="note" className={cn("my-1 border-l-2 pl-3", alert.borderClassName)}>
<div role="note" className={cn("my-1 border-s-2 ps-3", alert.borderClassName)}>
<p className={cn("flex items-center gap-1.5 font-medium", alert.titleClassName)}>
<alert.Icon aria-hidden className="size-3.5 shrink-0" />
{alert.label}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export interface CommandPaletteItem {
readonly value: string;
readonly searchTerms: ReadonlyArray<string>;
readonly title: ReactNode;
/**
* `"auto"` for titles that are prose the user or an agent wrote, so they read
* in their own direction. Left unset for the rest: this list also holds
* command names and file paths, and an identifier keeps the app's direction.
*/
readonly titleDir?: "auto";
readonly description?: ReactNode;
readonly threadContentMatch?: CommandPaletteThreadContentMatch;
readonly timestamp?: string;
Expand Down Expand Up @@ -156,6 +162,7 @@ export function buildProjectActionItems(input: {
value: `${input.valuePrefix}:${project.environmentId}:${project.id}`,
searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])],
title: project.title,
titleDir: "auto",
description: input.renderDescription?.(project) ?? project.workspaceRoot,
icon: input.icon(project),
...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}),
Expand Down Expand Up @@ -237,6 +244,7 @@ export function buildThreadActionItems<TThread extends BuildThreadActionItemsThr
contentMatch?.snippet ?? ``,
],
title: thread.title,
titleDir: "auto",
description,
timestamp: formatRelativeTimeLabel(
thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
Expand Down
20 changes: 15 additions & 5 deletions apps/web/src/components/CommandPaletteResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ function ThreadContentMatch(props: {
<span className={isUser ? "text-blue-400" : "text-emerald-400"}>
{isUser ? "You:" : "Agent:"}
</span>{" "}
<HighlightedSearchText text={props.match.snippet} query={props.match.query} />
<span dir="auto">
<HighlightedSearchText text={props.match.snippet} query={props.match.query} />
</span>
Comment thread
AsimNet marked this conversation as resolved.
</span>
);
}
Expand Down Expand Up @@ -136,7 +138,9 @@ function DisabledCommandPaletteResultRow(props: {
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<span dir={props.item.titleDir} className="truncate">
{props.item.title}
</span>
</span>
{props.item.threadContentMatch ? (
<ThreadContentMatch match={props.item.threadContentMatch} />
Expand All @@ -150,7 +154,9 @@ function DisabledCommandPaletteResultRow(props: {
) : (
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<span dir={props.item.titleDir} className="truncate">
{props.item.title}
</span>
</span>
)}
{props.item.titleTrailingContent}
Expand Down Expand Up @@ -187,7 +193,9 @@ function CommandPaletteResultRow(props: {
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<span dir={props.item.titleDir} className="truncate">
{props.item.title}
</span>
</span>
{props.item.threadContentMatch ? (
<ThreadContentMatch match={props.item.threadContentMatch} />
Expand All @@ -201,7 +209,9 @@ function CommandPaletteResultRow(props: {
) : (
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-sm text-foreground">
{props.item.titleLeadingContent}
<span className="truncate">{props.item.title}</span>
<span dir={props.item.titleDir} className="truncate">
{props.item.title}
</span>
</span>
)}
{props.item.titleTrailingContent}
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/components/LegacySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
{renamingThreadKey === threadKey ? (
<input
ref={handleRenameInputRef}
dir="auto"
className="min-w-0 flex-1 truncate rounded border border-ring bg-transparent px-0.5 text-sm outline-none"
value={renamingTitle}
onChange={handleRenameInputChange}
Expand All @@ -731,14 +732,19 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
<TooltipTrigger
render={
<span
dir="auto"
className="min-w-0 flex-1 truncate text-sm"
data-testid={`thread-title-${thread.id}`}
>
{thread.title}
</span>
}
/>
<TooltipPopup side="top" className="max-w-80 whitespace-normal leading-tight">
<TooltipPopup
dir="auto"
side="top"
className="max-w-80 whitespace-normal leading-tight"
>
{thread.title}
</TooltipPopup>
</Tooltip>
Expand Down
29 changes: 23 additions & 6 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,13 @@ function SidebarThreadTooltip({
align="start"
sideOffset={4}
variant="glass"
className="max-w-80 text-left whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0"
className="max-w-80 text-start whitespace-normal [&_[data-slot=tooltip-viewport]]:p-0"
>
<div className="flex min-w-0 max-w-80 flex-col gap-2 p-[var(--floating-content-inset)]">
<div className="min-w-0 truncate text-xs leading-none font-medium text-foreground">
<div
dir="auto"
className="min-w-0 truncate text-xs leading-none font-medium text-foreground"
>
{thread.title}
</div>
<div className="grid gap-1.5 pl-0.5 text-xs text-muted-foreground">
Expand All @@ -305,7 +308,9 @@ function SidebarThreadTooltip({
faviconPath={projectFaviconPath}
className="size-3 shrink-0 stroke-muted-foreground"
/>
<div className="min-w-0 truncate text-foreground/75">{projectTitle}</div>
<div dir="auto" className="min-w-0 truncate text-foreground/75">
{projectTitle}
</div>
</div>
) : null}
{environmentLabel ? (
Expand Down Expand Up @@ -537,7 +542,10 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
faviconPath={props.projectFaviconPath}
className="size-4 shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label">
<span
dir="auto"
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
className="min-w-0 flex-1 truncate text-xs font-medium text-secondary-label"
>
{props.projectTitle}
</span>
<span className="ml-auto flex h-5 min-w-5 shrink-0 items-center justify-end">
Expand All @@ -558,7 +566,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
</Tooltip>
</span>
</div>
<div className="mt-0.5 truncate text-sm font-medium text-foreground/90">{preview}</div>
<div dir="auto" className="mt-0.5 truncate text-sm font-medium text-foreground/90">
{preview}
</div>
</div>
</div>
</li>
Expand Down Expand Up @@ -1116,6 +1126,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
const title = isRenaming ? (
<input
autoFocus
dir="auto"
value={renamingTitle}
aria-label="Thread title"
onChange={(event) => onRenameTitleChange(event.target.value)}
Expand All @@ -1128,6 +1139,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
) : (
<span
// Titles are generated from the thread's own prompt, so an Arabic thread
// gets an Arabic title — and its truncation ellipsis belongs on the left.
dir="auto"
className={cn(
"min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none",
shouldRecede ? "font-normal" : "font-medium",
Expand Down Expand Up @@ -1378,6 +1392,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
/>
{props.projectTitle ? (
<span
dir="auto"
className={cn(
"min-w-0 flex-1 truncate text-secondary-label text-xs",
shouldRecede ? "font-normal" : "font-medium",
Expand Down Expand Up @@ -1676,7 +1691,9 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
className="size-4 shrink-0"
fallbackIcon={MessageSquareIcon}
/>
<span className="min-w-0 flex-1 truncate">{thread.title}</span>
<span dir="auto" className="min-w-0 flex-1 truncate">
{thread.title}
</span>
<span className="shrink-0 text-xs text-muted-foreground/55 tabular-nums">
{threadTimeLabel(thread)}
</span>
Expand Down
Loading
Loading