diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx
index 9499ee5a6915..c5cf63efeecf 100644
--- a/apps/web/src/components/ChatMarkdown.test.tsx
+++ b/apps/web/src/components/ChatMarkdown.test.tsx
@@ -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", () => {
@@ -34,3 +35,61 @@ describe("orderedListGutterStyle", () => {
expect(orderedListGutterStyle(0, undefined)).toBeUndefined();
});
});
+
+describe("chat markdown text direction", () => {
+ function render(text: string) {
+ return renderToStaticMarkup(
English first.
'); + expect(html).toContain('مرحبا بالعالم.
'); + }); + + it("marks headings, lists, and quotes so their markers follow the text", () => { + const html = render("# عنوان\n\n- عنصر\n\n> اقتباس"); + expect(html).toContain(''); + }); + + 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("- "); + expect(html).not.toContain("
- \n
'); + }); + + 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('
'); + expect(html).toContain('
git status'); + expect(html).toContain('{ + // 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('مرحبا بالعالم.
'); + expect(html).not.toContain("{ + // The `code` renderer swaps the chip in for the `` it + // replaces, so a path in an Arabic sentence keeps its own reading order. + const html = render("عدّل `src/main.ts` من فضلك."); + expect(html).toContain(' { + const html = render("| اسم | value |\n| --- | --- |\n| قيمة | 1 |"); + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain(' '); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c3e1c5288da7..1c7c6de21b56 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -195,6 +195,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS = [ remarkNormalizeListItemIndentation, remarkPreserveCodeMeta, remarkTagInlineCode, + remarkTextDirection, ] satisfies NonNullable ; const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ @@ -204,6 +205,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkBreaks, remarkPreserveCodeMeta, remarkTagInlineCode, + remarkTextDirection, ] satisfies NonNullable ; const CHAT_MARKDOWN_REHYPE_PLUGINS = [ @@ -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", + "heading", + "list", + "paragraph", + "tableCell", +]); +const LTR_DIRECTION_NODE_TYPES = new Set(["code", "inlineCode", "table"]); + +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)); + }; + + visit(tree, false); + }; +} + function nodeToPlainText(node: ReactNode): string { if (typeof node === "string" || typeof node === "number") { return String(node); @@ -671,6 +741,9 @@ function MarkdownCodeBlock({ return ( ` 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} @@ -1570,7 +1647,7 @@ function ChatMarkdown({ // Not a: the stylesheet mutes those, and an alert's body is ordinary // text under a colored title — which is how the host renders it. return ( -+{comment.text.length > 0 && ( -@@ -1116,6 +1126,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const title = isRenaming ? ( onRenameTitleChange(event.target.value)} @@ -1128,6 +1139,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> ) : ( {props.projectTitle ? ( - {thread.title} + + {thread.title} + {threadTimeLabel(thread)} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index d032b16a186b..38008344477e 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -253,7 +253,9 @@ export const ChatHeader = memo(function ChatHeader({ faviconPath={activeProjectFaviconPath} className="size-3.5" /> - {activeProjectName} + + {activeProjectName} +
{alert.label} diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 688a8a8ea791..d43014d92787 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -87,6 +87,12 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray ; 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; @@ -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 } : {}), @@ -237,6 +244,7 @@ export function buildThreadActionItems {isUser ? "You:" : "Agent:"} {" "} - + + + ); } @@ -136,7 +138,9 @@ function DisabledCommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + {props.item.threadContentMatch ? ( @@ -150,7 +154,9 @@ function DisabledCommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + )} {props.item.titleTrailingContent} @@ -187,7 +193,9 @@ function CommandPaletteResultRow(props: { {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + {props.item.threadContentMatch ? ( @@ -201,7 +209,9 @@ function CommandPaletteResultRow(props: { ) : ( {props.item.titleLeadingContent} - {props.item.title} + + {props.item.title} + )} {props.item.titleTrailingContent} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index b4f3b5996818..ebbe2244c63a 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -718,6 +718,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr {renamingThreadKey === threadKey ? ( @@ -738,7 +740,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr } /> - + {thread.title} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a88bc9ce4bb7..2dafcd8c69a2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -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" >-+-{thread.title}@@ -305,7 +308,9 @@ function SidebarThreadTooltip({ faviconPath={projectFaviconPath} className="size-3 shrink-0 stroke-muted-foreground" /> -) : null} {environmentLabel ? ( @@ -537,7 +542,10 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { faviconPath={props.projectFaviconPath} className="size-4 shrink-0" /> - + {props.projectTitle} @@ -558,7 +566,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {{projectTitle}++ {projectTitle} +{preview}++ {preview} +New thread in {activeProjectName} @@ -265,6 +267,7 @@ export const ChatHeader = memo(function ChatHeader({ {renamingTitle !== null ? ( } > -{activeThreadTitle}
++ {activeThreadTitle} +
- {activeThreadTitle} ++ {activeThreadTitle} + ) : ()} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ea6cda9b6208..2c2945fd6750 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -607,7 +607,9 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain(' + {activeThreadTitle}
} /> -{activeThreadTitle} ++ {activeThreadTitle} + <tag attr="x">'); + expect(markup).toContain( + '<tag attr="x">', + ); expect(markup).toContain("<root><child enabled="true" /></root>"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d89140d1105..bab72e696583 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1962,7 +1962,10 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( -+{inlineNodes}); @@ -2001,6 +2004,9 @@ const UserMessageBody = memo(function UserMessageBody(props: { } return ( + // No `dir` here: the terminal-context chips always come first, so the + // wrapper could only ever resolve from their label. The message text below + // them is markdown, and each of its blocks picks its own direction.{inlineNodes}@@ -2043,7 +2049,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte+)} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 71f9443f7f62..7e75b984a512 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2034,7 +2034,9 @@ code { custom property, so without this a task-list under a 3+ digit ordered list would inherit the outer gutter instead of its own default. */ --list-gutter: 1.25rem; - padding-left: 1.25rem; + /* Logical, because a list whose text is Arabic or Hebrew carries dir="auto" + and paints its markers on the right — the gutter has to move with them. */ + padding-inline-start: 1.25rem; list-style-type: disc; } @@ -2045,7 +2047,7 @@ code { nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { --list-gutter: 1.25rem; - padding-left: var(--list-gutter, 1.25rem); + padding-inline-start: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -2075,7 +2077,8 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); + margin: 0 0 0.15em; + margin-inline: calc(-1 * var(--list-gutter, 1.25rem)) 0.35em; vertical-align: middle; } @@ -2099,8 +2102,8 @@ code { } .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); } @@ -2114,7 +2117,7 @@ code { .chat-markdown section[data-footnotes] ol { margin: 0; - padding-left: 1.25rem; + padding-inline-start: 1.25rem; } .chat-markdown section[data-footnotes] li + li { @@ -2226,7 +2229,9 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + /* Logical: the table is pinned LTR so its columns keep their source order, + but each cell carries dir="auto" and aligns to its own text. */ + text-align: start; } .chat-markdown thead th {