From 4168242ba1258739793f858aca44854adc292a70 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:28:03 +0300 Subject: [PATCH] fix(desktop): tables follow RTL message direction Markdown tables were left out of the bidi work from #44596: an RTL table (Hebrew, Arabic, ...) kept LTR column order with headers and cells pinned to the left, because the plaintext rules only cover prose blocks and the th was hardcoded text-left. Hang dir="auto" on the table so the browser resolves column order and a single base direction from the cells' content, in practice the column headers (a box-direction property unicode-bidi:plaintext cannot set and no CSS selector can read off the script). Cells then get unicode-bidi:plaintext so a value like "-3%" or "123 units" keeps its authored order instead of being reordered by the surrounding RTL run, and text-align is pinned to the table's resolved edge via :dir() so a column's header and values stay on the same side. English tables are unchanged. --- .../assistant-ui/markdown-table.tsx | 3 +- .../assistant-ui/table-direction.test.tsx | 124 ++++++++++++++++++ apps/desktop/src/styles.css | 22 ++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/components/assistant-ui/table-direction.test.tsx diff --git a/apps/desktop/src/components/assistant-ui/markdown-table.tsx b/apps/desktop/src/components/assistant-ui/markdown-table.tsx index 1fcec60002ed8..ec3062cb3e8e2 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-table.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-table.tsx @@ -156,6 +156,7 @@ export function ResizableMarkdownTable({ children, className, ...props }: Compon return (
so the browser resolves column order +// from the cells' content, and aligns headers/cells with text-align:start +// (text-start, not a pinned text-left) so they follow that resolved direction. +// jsdom does not resolve dir="auto" or apply the stylesheet, so the contract is +// asserted at the attribute/class level. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Thread } from './thread' + +const createdAt = new Date('2026-06-01T00:00:00.000Z') + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) + +Element.prototype.scrollTo = function scrollTo() {} + +function stubOffsetDimension( + prop: 'offsetHeight' | 'offsetWidth', + clientProp: 'clientHeight' | 'clientWidth', + fallback: number +) { + const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop) + + Object.defineProperty(HTMLElement.prototype, prop, { + configurable: true, + get() { + return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback + } + }) +} + +stubOffsetDimension('offsetWidth', 'clientWidth', 800) +stubOffsetDimension('offsetHeight', 'clientHeight', 600) + +function userMessage(): ThreadMessage { + return { + id: 'user-1', + role: 'user', + content: [{ type: 'text', text: 'hi' }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistantMessage(text: string): ThreadMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: [{ type: 'text', text }], + status: { type: 'complete', reason: 'stop' }, + createdAt, + metadata: { + unstable_state: null, + unstable_annotations: [], + unstable_data: [], + steps: [], + custom: {} + } + } as ThreadMessage +} + +function Harness({ text }: { text: string }) { + const runtime = useExternalStoreRuntime({ + messages: [userMessage(), assistantMessage(text)], + isRunning: false, + onNew: async () => {} + }) + + return ( + + + + ) +} + +const HEBREW_TABLE = [ + '| שם פריט | כמות | מחיר |', + '| --- | --- | --- |', + '| תפוחים | 12 | 18 |', + '| בננות | 8 | 14 |' +].join('\n') + +const ENGLISH_TABLE = ['| Item | Qty | Price |', '| --- | --- | --- |', '| Apples | 12 | 4.50 |'].join('\n') + +afterEach(cleanup) + +describe('markdown table direction', () => { + it('a Hebrew table carries dir="auto" so the browser resolves column order from content', async () => { + render() + + const cell = await screen.findByText('תפוחים') + + expect(cell.closest('table')?.getAttribute('dir')).toBe('auto') + }) + + it('header cells use logical alignment so headers follow the resolved direction', async () => { + render() + + const header = (await screen.findByText('שם פריט')).closest('th')! + + expect(header.className).toContain('text-start') + expect(header.className).not.toContain('text-left') + cleanup() + render() + + const cell = await screen.findByText('Apples') + + expect(cell.closest('table')?.getAttribute('dir')).toBe('auto') + }) +}) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 7ac9db15cf22b..3bf9f003c2b16 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -1583,6 +1583,28 @@ body.guest-pointer-lock :is(webview, iframe) { text-align: start; } +/* Table cells follow the table's single resolved direction. The
+ carries dir="auto" (markdown-table.tsx) so the browser derives that one + direction, and the column order, from the cells' content (in practice the + column headers). plaintext keeps each cell's inline content in its authored + order, so a value like "18 ₪", "-3%" or "123 units" is never reordered by the + surrounding RTL run. :dir() then pins every header and cell to the table's + resolved edge, so a column's header and values stay on the same side instead + of splitting (an RTL header right, a numeric value left). Physical right/left + is deliberate here: the side is chosen by the table's resolved direction, not + the cell's own. Out-specifies the prose cell defaults. */ +[data-slot='aui_assistant-message-content'] .aui-md :where(th, td) { + unicode-bidi: plaintext; +} + +[data-slot='aui_assistant-message-content'] .aui-md table:dir(rtl) :where(th, td) { + text-align: right; +} + +[data-slot='aui_assistant-message-content'] .aui-md table:dir(ltr) :where(th, td) { + text-align: left; +} + /* Inline code/KaTeX don't vote on direction and keep their own order: isolate makes bidi treat each as one neutral, so a block that *starts* with `./run.sh` then Arabic still resolves RTL, and the command's neutrals (dots/slashes)