Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,7 @@ export function ChatBar({
contentEditable={!disabled}
data-placeholder={placeholder}
data-slot={RICH_INPUT_SLOT}
dir="auto"
onBlur={() => window.setTimeout(closeTrigger, 80)}
onCompositionEnd={event => {
composingRef.current = false
Expand Down
94 changes: 83 additions & 11 deletions apps/desktop/src/components/assistant-ui/markdown-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ import {
type SyntaxHighlighterProps
} from '@assistant-ui/react-streamdown'
import { code } from '@streamdown/code'
import { type ComponentProps, memo, type ReactNode, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import {
type ComponentProps,
isValidElement,
memo,
type ReactNode,
useDeferredValue,
useEffect,
useMemo,
useRef,
useState
} from 'react'

import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { SyntaxHighlighter } from '@/components/chat/shiki-highlighter'
Expand All @@ -26,6 +36,7 @@ import {
mediaStreamUrl
} from '@/lib/media'
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
import { textDirection } from '@/lib/text-direction'
import { cn } from '@/lib/utils'

// Math rendering plugin (KaTeX). Configured once at module scope โ€” the
Expand Down Expand Up @@ -164,6 +175,40 @@ function MediaAttachment({ path }: { path: string }) {
)
}

// Block direction comes from the block's prose: code spans and math don't
// get a vote, so a paragraph that *starts* with `./script.sh` or `npm ...`
// still right-aligns when the sentence around it is Hebrew/Arabic. Blocks
// with no prose at all fall back to dir="auto" (plain first-strong).
function proseText(node: ReactNode): string {
if (typeof node === 'string' || typeof node === 'number') {
return String(node)
}

if (Array.isArray(node)) {
return node.map(proseText).join('')
}

if (isValidElement(node)) {
const props = node.props as { children?: ReactNode; className?: string; node?: { tagName?: string } }

if (
node.type === 'code' ||
props.node?.tagName === 'code' ||
(typeof props.className === 'string' && props.className.includes('katex'))
) {
return ''
}

return proseText(props.children)
}

return ''
}

function proseDir(children: ReactNode): 'auto' | 'ltr' | 'rtl' {
return textDirection(proseText(children)) ?? 'auto'
}

function childrenToText(children: unknown): string {
if (typeof children === 'string' || typeof children === 'number') {
return String(children).trim()
Expand Down Expand Up @@ -386,40 +431,67 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
() =>
({
h1: ({ className, ...props }: ComponentProps<'h1'>) => (
<h1 className={cn('my-1 font-semibold', HEADING_SIZES.h1, className)} {...props} />
<h1
className={cn('my-1 font-semibold text-start', HEADING_SIZES.h1, className)}
dir={proseDir(props.children)}
{...props}
/>
),
h2: ({ className, ...props }: ComponentProps<'h2'>) => (
<h2 className={cn('my-1 font-semibold', HEADING_SIZES.h2, className)} {...props} />
<h2
className={cn('my-1 font-semibold text-start', HEADING_SIZES.h2, className)}
dir={proseDir(props.children)}
{...props}
/>
),
h3: ({ className, ...props }: ComponentProps<'h3'>) => (
<h3 className={cn('my-1 font-semibold', HEADING_SIZES.h3, className)} {...props} />
<h3
className={cn('my-1 font-semibold text-start', HEADING_SIZES.h3, className)}
dir={proseDir(props.children)}
{...props}
/>
),
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
<h4 className={cn('my-1 font-semibold', HEADING_SIZES.h4, className)} {...props} />
<h4
className={cn('my-1 font-semibold text-start', HEADING_SIZES.h4, className)}
dir={proseDir(props.children)}
{...props}
/>
),
p: ({ className, ...props }: ComponentProps<'p'>) => (
// Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
// must out-specify Tailwind Typography's `prose` margins โ€” so no
// `my-*` here on purpose.
<p className={cn('wrap-anywhere leading-(--dt-line-height)', className)} {...props} />
// `my-*` here on purpose. Direction + `text-start` let each
// paragraph right-align when its prose is RTL without affecting
// LTR content (see proseDir).
<p
className={cn('wrap-anywhere text-start leading-(--dt-line-height)', className)}
dir={proseDir(props.children)}
{...props}
/>
),
a: MarkdownLink,
// `---` as quiet spacing, not a heavy full-width rule.
hr: (_props: ComponentProps<'hr'>) => <div aria-hidden className="my-3" />,
blockquote: ({ className, ...props }: ComponentProps<'blockquote'>) => (
<blockquote
className={cn('border-l-2 border-border pl-3 text-muted-foreground italic', className)}
className={cn('border-s-2 border-border ps-3 text-start text-muted-foreground italic', className)}
dir={proseDir(props.children)}
{...props}
/>
),
ul: ({ className, ...props }: ComponentProps<'ul'>) => (
<ul className={cn('my-1 gap-0', className)} {...props} />
<ul className={cn('my-1 gap-0', className)} dir={proseDir(props.children)} {...props} />
),
ol: ({ className, ...props }: ComponentProps<'ol'>) => (
<ol className={cn('my-1 gap-0', className)} {...props} />
<ol className={cn('my-1 gap-0', className)} dir={proseDir(props.children)} {...props} />
),
li: ({ className, ...props }: ComponentProps<'li'>) => (
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
<li
className={cn('text-start leading-(--dt-line-height)', className)}
dir={proseDir(props.children)}
{...props}
/>
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
Expand Down
143 changes: 143 additions & 0 deletions apps/desktop/src/components/assistant-ui/message-direction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Message text must resolve its direction from its own prose so RTL
// scripts (Hebrew, Arabic) render right-aligned and correctly ordered per
// block, while code stays LTR. Code spans don't get a vote: a technical
// RTL message often *starts* with a command, which would flip plain
// first-strong detection to LTR. jsdom does not compute visual direction,
// so these tests pin the contract that drives it in the browser: prose
// blocks carry the resolved dir attribute, code blocks never carry one.
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-05-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(text: string): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text }],
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({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: false,
onNew: async () => {}
})

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

describe('message text direction', () => {
it('user message text resolves direction from content while fences stay LTR', async () => {
render(<Harness messages={[userMessage('ืฉืœื•ื ืขื•ืœื\n```\nnpm run dev\n```')]} />)

const text = await screen.findByText(/ืฉืœื•ื ืขื•ืœื/)

expect(text.closest('[dir]')?.getAttribute('dir')).toBe('rtl')

const code = screen.getByText(/npm run dev/)

expect(code.closest('pre')).not.toBeNull()
expect(code.closest('[dir]')).toBeNull()
})

it('user message starting with inline code still follows its prose', async () => {
render(<Harness messages={[userMessage('`./scripts/run.sh -v` ืžื” ื”ืคืงื•ื“ื” ื”ื–ืืช ืขื•ืฉื”?')]} />)

const text = await screen.findByText(/ืžื” ื”ืคืงื•ื“ื” ื”ื–ืืช ืขื•ืฉื”/)

expect(text.closest('[dir]')?.getAttribute('dir')).toBe('rtl')
})

it('assistant prose blocks resolve direction per block', async () => {
render(<Harness messages={[userMessage('hi'), assistantMessage('ืฉืœื•ื ืœื›ื•ืœื\n\n- ืคืจื™ื˜ ืจืืฉื•ืŸ\n- second item')]} />)

const paragraph = await screen.findByText(/ืฉืœื•ื ืœื›ื•ืœื/)

expect(paragraph.closest('p')?.getAttribute('dir')).toBe('rtl')

const item = await screen.findByText(/ืคืจื™ื˜ ืจืืฉื•ืŸ/)

expect(item.closest('li')?.getAttribute('dir')).toBe('rtl')
expect(item.closest('ul')?.getAttribute('dir')).toBe('rtl')
})

it('assistant paragraphs starting with inline code follow their prose', async () => {
render(
<Harness
messages={[
userMessage('hi'),
assistantMessage('`npm run dev` ืžืคืขื™ืœ ืืช ืกื‘ื™ื‘ืช ื”ืคื™ืชื•ื—.\n\n`npm run dev` starts the dev environment.')
]}
/>
)

const rtl = await screen.findByText(/ืžืคืขื™ืœ ืืช ืกื‘ื™ื‘ืช ื”ืคื™ืชื•ื—/)

expect(rtl.closest('p')?.getAttribute('dir')).toBe('rtl')

const ltr = await screen.findByText(/starts the dev environment/)

expect(ltr.closest('p')?.getAttribute('dir')).toBe('ltr')
})
})
3 changes: 2 additions & 1 deletion apps/desktop/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1530,14 +1530,15 @@ const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sessionId }
aria-label={copy.editMessage}
autoFocus
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-start text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground/95 outline-none',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}
contentEditable
data-placeholder={copy.editMessage}
data-slot={RICH_INPUT_SLOT}
dir="auto"
onBlur={() => window.setTimeout(closeTrigger, 80)}
onDragOver={handleDragOver}
onDrop={handleDrop}
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src/components/assistant-ui/user-message-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FC } from 'react'
import { Fragment, useMemo } from 'react'

import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { textDirection } from '@/lib/text-direction'
import { cn } from '@/lib/utils'

// User messages should render the bare-minimum of markdown: backtick `code`
Expand Down Expand Up @@ -126,8 +127,23 @@ export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) =
const InlineSegmentView: FC<{ text: string }> = ({ text }) => {
const nodes = useMemo(() => splitInlineCode(text), [text])

// Direction comes from the segment's prose; inline code doesn't get a
// vote, so a message that *starts* with a command still right-aligns
// when the sentence around it is Hebrew/Arabic. `text-start` follows the
// resolved direction (the bubble itself is `text-left`); fences stay LTR.
const dir = useMemo(
() =>
textDirection(
nodes
.filter(node => node.kind === 'inline-text')
.map(node => node.text)
.join('')
) ?? 'auto',
[nodes]
)

return (
<span className="wrap-anywhere block whitespace-pre-line">
<span className="wrap-anywhere block whitespace-pre-line text-start" dir={dir}>
{nodes.map((node, nodeIndex) =>
node.kind === 'inline-code' ? (
<code
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/lib/text-direction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// First-strong direction detection, same heuristic as dir="auto" but over a
// caller-chosen slice of the text. Callers strip code spans before asking:
// a technical RTL message usually *starts* with a command or identifier,
// which would flip first-strong to LTR even though the sentence is
// Hebrew/Arabic. U+0590-U+08FF is RTL scripts end to end (Hebrew, Arabic,
// Syriac, Thaana, NKo, Samaritan, Mandaic and their extensions).
const RTL_CHAR = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/
const FIRST_LETTER = /\p{L}/u

export function textDirection(text: string): 'ltr' | 'rtl' | null {
const first = text.match(FIRST_LETTER)

if (!first) {
return null
}

return RTL_CHAR.test(first[0]) ? 'rtl' : 'ltr'
}
18 changes: 18 additions & 0 deletions apps/desktop/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,24 @@ canvas {
color: var(--ui-inline-code-foreground);
}

/* Inside a direction-resolved block (the dir attribute is only ever set on
message blocks and the composer), inline code and KaTeX math keep their
internal LTR order: their neutrals (dots, slashes, dashes) would
otherwise be reordered by an RTL paragraph's bidi run. `isolate` stops
the run from leaking into the sentence around it. */
[dir] :is(code, .katex) {
direction: ltr;
unicode-bidi: isolate;
}

/* Code blocks are LTR surfaces even when they sit inside an RTL list item
or blockquote: pin them so the card chrome and code lines don't mirror. */
[data-slot='code-card'],
[data-streamdown='code-block'],
[data-slot='aui_user-fence'] {
direction: ltr;
}

[data-slot='aui_assistant-message-content'] .aui-md :where(.aui-shiki, .aui-shiki > pre) {
margin: 0 !important;
}
Expand Down