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'>) => (