Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/desktop/src/components/assistant-ui/markdown-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function ResizableMarkdownTable({ children, className, ...props }: Compon
return (
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-(--ui-stroke-tertiary)">
<table
dir="auto"
className={cn(
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-(--ui-stroke-tertiary) last:[&_tr]:border-0',
widths && 'table-fixed [&_td]:wrap-anywhere',
Expand Down Expand Up @@ -183,7 +184,7 @@ export function ResizableMarkdownTh({ children, className, ...props }: Component
return (
<th
className={cn(
'relative px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
'relative px-2.5 py-1.5 text-start align-middle text-[0.75rem] font-medium text-muted-foreground',
// The trailing column has no seam: its right edge is the table's edge,
// and there is nothing on the far side to trade width with.
'[&:last-child_[data-md-col-handle]]:hidden',
Expand Down
124 changes: 124 additions & 0 deletions apps/desktop/src/components/assistant-ui/table-direction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// A markdown table's column order is its box `direction`, which the
// unicode-bidi:plaintext rules that own per-block text direction never touch.
// The fix hangs dir="auto" on the <table> 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<ThreadMessage>({
messages: [userMessage(), assistantMessage(text)],
isRunning: false,
onNew: async () => {}
})

return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}

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(<Harness text={HEBREW_TABLE} />)

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(<Harness text={HEBREW_TABLE} />)

const header = (await screen.findByText('שם פריט')).closest('th')!

expect(header.className).toContain('text-start')
expect(header.className).not.toContain('text-left')
cleanup()
render(<Harness text={ENGLISH_TABLE} />)

const cell = await screen.findByText('Apples')

expect(cell.closest('table')?.getAttribute('dir')).toBe('auto')
})
})
22 changes: 22 additions & 0 deletions apps/desktop/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,28 @@ body.guest-pointer-lock :is(webview, iframe) {
text-align: start;
}

/* Table cells follow the table's single resolved direction. The <table>
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)
Expand Down