From f4100f439430c55530e1e398c90dd94ead292b0c Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:20:01 +0300 Subject: [PATCH] fix(desktop): list markers and quote border follow RTL message direction unicode-bidi:plaintext (#44596) resolves text direction per line, but list markers and the blockquote border are box chrome driven by the CSS direction property, which plaintext never sets, so an RTL list renders its numbers stranded at the far left edge. CSS cannot close this gap (:dir() only reads the dir attribute, never plaintext resolution), so ul/ol/blockquote carry dir="auto" and the browser resolves their box direction natively while the plaintext rules keep owning the text. Inline code carries dir="ltr", which HTML's auto algorithm skips, matching the no-vote contract the CSS isolate already gives it. --- .../assistant-ui/block-direction.test.tsx | 129 ++++++++++++++++++ .../components/assistant-ui/markdown-text.tsx | 24 +++- 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/block-direction.test.tsx diff --git a/apps/desktop/src/components/assistant-ui/block-direction.test.tsx b/apps/desktop/src/components/assistant-ui/block-direction.test.tsx new file mode 100644 index 00000000000..a206e8e847d --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/block-direction.test.tsx @@ -0,0 +1,129 @@ +// Lists and blockquotes have chrome beside the text (markers, the quote +// border) whose side is driven by the box's CSS direction, which the +// unicode-bidi:plaintext rules never touch. These tests pin the split of +// responsibilities: ul/ol/blockquote carry dir="auto" so the browser +// resolves their box direction from content, inline code carries dir="ltr" +// so it neither votes in that resolution nor reorders, and plain prose +// blocks stay attribute-free (the plaintext CSS owns them). jsdom does not +// resolve dir="auto", so the contract is asserted at the attribute level. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { render, screen } from '@testing-library/react' +import { 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 ( + + + + ) +} + +describe('block-level direction chrome', () => { + it('lists carry dir="auto" so markers follow the resolved direction', async () => { + render() + + const item = await screen.findByText(/חוף גורדון/) + + expect(item.closest('ol')?.getAttribute('dir')).toBe('auto') + + const bullet = await screen.findByText(/פריט/) + + expect(bullet.closest('ul')?.getAttribute('dir')).toBe('auto') + }) + + it('blockquotes carry dir="auto" so the border follows the resolved direction', async () => { + render( ציטוט קצר בעברית'} />) + + const quote = await screen.findByText(/ציטוט קצר/) + + expect(quote.closest('blockquote')?.getAttribute('dir')).toBe('auto') + }) + + it('inline code carries dir="ltr" so it does not vote in dir="auto" resolution', async () => { + render() + + const code = await screen.findByText('npm install') + + expect(code.tagName).toBe('CODE') + expect(code.getAttribute('dir')).toBe('ltr') + expect(code.closest('ol')?.getAttribute('dir')).toBe('auto') + }) + + it('plain prose blocks stay attribute-free (plaintext CSS owns them)', async () => { + render() + + const paragraph = await screen.findByText(/שלום לכולם/) + + expect(paragraph.closest('p')?.hasAttribute('dir')).toBe(false) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx index 2c87f6d0c33..c5ca96a3a2e 100644 --- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx +++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx @@ -484,19 +484,37 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex

), a: MarkdownLink, + // Inline code must not vote when an ancestor resolves `dir="auto"` + // (HTML's algorithm skips descendants that carry their own dir), + // mirroring the CSS isolate that already keeps it out of the + // plaintext scan. Fenced code never reaches this override; it goes + // through the code plugin's CodeCard path. + inlineCode: ({ className, ...props }: ComponentProps<'code'>) => ( + + ), // `---` as quiet spacing, not a heavy full-width rule. hr: (_props: ComponentProps<'hr'>) =>

, + // Lists and blockquotes have chrome that sits *beside* the text + // (markers, the quote border), and that side is driven by the CSS + // `direction` of the box, which `unicode-bidi: plaintext` never + // touches — an RTL list otherwise renders its numbers stranded at + // the far left. `dir="auto"` lets the browser resolve the box + // direction from content; the plaintext rules in styles.css keep + // owning per-line text direction. Inline code carries `dir="ltr"` + // (see the `code` override) so it doesn't vote here either, same + // contract as the CSS isolate. blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
), ul: ({ className, ...props }: ComponentProps<'ul'>) => ( -
    +
      ), ol: ({ className, ...props }: ComponentProps<'ol'>) => ( -
        +
          ), li: ({ className, ...props }: ComponentProps<'li'>) => (