Skip to content

desktop: nested markdown blocks (li, td, blockquote p) render LTR when message dir=auto resolves LTR #54471

Open

Description

@majiabdol

Summary

In the desktop/web app, bidirectional text inside nested Markdown blocks (<li>, <td>, <th>, and <p> inside <blockquote>) is laid out LTR when the surrounding message resolves to LTR, which scrambles Persian/Arabic/Hebrew content. Only direct children of the Markdown container get unicode-bidi: plaintext, so nested blocks fall back to the container's dir="auto" direction.

Environment

  • opencode version: 2.0.22 (desktop app, @opencodedesktop, renderer bundle [email protected])
  • OS: Windows_NT 10.0.26300 (win32 x64)
  • Terminal: Unavailable: not applicable — this is the Electron desktop app, not a TUI session
  • Shell: C:\WINDOWS\system32\cmd.exe
  • Install/channel: release (desktop installer)
  • Active plugins: none found in config

Reproduction

  1. Use the desktop app with an RTL language for assistant output (e.g. Persian).

  2. Ask a question where the assistant reply begins with an English heading or code and then contains RTL body content, for example:

    ## Project setup
    
    Run `npm install` first, then:
    
    - گام اول: دانلود ابزار از سایت رسمی
    - گام دوم: اجرای دستور نصب و بررسی نسخه
    
    > توجه: این مرحله را حتماً قبل از اجرا انجام دهید.
    
    | نام | مقدار |
    |-----|-------|
    | نسخه | 2.0.22 |
  3. Inspect the rendered result, or measure the computed styles of the nested elements.

Expected Behavior

<li>, <td>, <th>, and <blockquote> > p containing RTL text should lay out right-to-left, matching how a top-level RTL paragraph in the same message is laid out.

Actual Behavior

They lay out left-to-right, because they inherit the container's dir="auto"-resolved direction instead of resolving from their own first strong character.

Measured with the shipped CSS (assets/main-40jLuXnQ.css) and the real Markdown DOM (<div data-component=markdown dir=auto>), for a container that resolved to LTR:

Element unicode-bidi computed direction visual start
<p> (direct child) plaintext ltr right ✅
<li> (Persian) isolate ltr left ❌
<blockquote> > p (Persian) isolate ltr left ❌
<th> / <td> (Persian) isolate ltr left ❌
<code> isolate ltr left ✅ (correct)

Additional Context

Root cause. The Markdown component renders <div data-component=markdown dir=auto> and the stylesheet contains:

[data-component=markdown]>* { unicode-bidi: plaintext }

The * only matches direct children. Descendants such as li, td, th, and p inside blockquote keep the UA default unicode-bidi: isolate (verified on bare elements in Chromium) and inherit direction from the container. unicode-bidi: plaintext on an ancestor does not change its computed direction, so nested blocks never inherit an RTL direction — they only inherit whatever dir="auto" derived from the message's first strong character.

Because dir="auto" is evaluated per message, any reply whose first strong character is Latin (very common: headings such as ## Project setup, or code such as npm install) makes the whole container LTR, and every nested RTL block inside is scrambled. Replies that begin with an RTL character render correctly, which makes the bug look intermittent.

Suggested fix. Extend plaintext to nested block-level elements while leaving code isolated:

[data-component=markdown] :where(ul,ol,li,menu,dl,dt,dd,table,thead,tbody,tfoot,tr,td,th,blockquote,figure,figcaption,p,h1,h2,h3,h4,h5,h6,div){unicode-bidi:plaintext}

This was verified against the shipped stylesheet: li / td / th / blockquote > p flip to correct RTL alignment, English-only blocks flip to correct LTR alignment, and :not(pre)>code and [data-component=markdown-code] keep direction:ltr; unicode-bidi:isolate because their rules have higher specificity.

Workarounds tried, neither helps.

  • Setting the app language to Persian: this sets document.documentElement.dir = "rtl", but dir="auto" on each Markdown container re-derives LTR per message, so nested RTL blocks stay scrambled. Only the surrounding UI chrome becomes RTL.
  • Forcing layout direction from the diagnostics bar (setDirection("rtl")): same result; it only writes document.documentElement.dir, and it is not persisted across restarts (ma({direction: void 0}) is an in-memory store).

Activity

  1. opencode-agent commented on Oct 11, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I was able to reproduce this.

    I loaded the shipped markdown.css from v2.0.22 into headless Chromium and rendered the same <div data-component="markdown" dir="auto"> markup the Markdown component produces, using your example: an English heading and inline code first, then Persian text in a paragraph, a list, a blockquote and a table. I didn't run the packaged desktop app itself.

    • The top-level Persian <p> has unicode-bidi: plaintext and lays out right-to-left.
    • <li>, <blockquote> > p, <th> and <td> keep unicode-bidi: isolate, inherit direction: ltr from the container and lay out left-to-right.

    The result is the same on the latest dev (e31136bd): the stylesheet still applies plaintext only to direct children. With the nested-block rule you suggested added, all of those blocks lay out right-to-left.

    The script below reproduces it, and the screenshot shows the result on v2.0.22.

    Screenshot

    Reproduction script (repro.ts)
    // Reproduces #54471: nested markdown blocks (li, td, th, blockquote>p) lay out LTR
    // when the markdown container's dir="auto" resolves to LTR.
    // Loads the real packages/session-ui/src/components/markdown.css from a git ref
    // (default v2.0.22; pass another ref, e.g. refs/remotes/default, as argv[2]) into
    // headless Chromium with the DOM the Markdown component renders
    // (<div data-component="markdown" dir="auto">), then measures each block.
    // Usage: bun repro.ts [gitRef]   (needs: bun add playwright && bunx playwright install chromium)
    import { chromium } from "playwright"
    import { execSync } from "node:child_process"
    
    const ref = process.argv[2] ?? "v2.0.22"
    const repo = process.env.REPO ?? "/workspace/repo"
    const css = execSync(`git -C ${repo} show ${ref}:packages/session-ui/src/components/markdown.css`).toString()
    
    const html = `<!doctype html><html><head><meta charset="utf-8"><style>${css}
    body{font-family:sans-serif;width:700px} td,th{border:1px solid #999;width:300px}</style></head><body>
    <div data-component="markdown" dir="auto">
    <h2>Project setup</h2>
    <p>Run <code>npm install</code> first, then:</p>
    <p id="p">این یک پاراگراف فارسی در سطح بالا است.</p>
    <ul><li id="li">گام اول: دانلود ابزار از سایت رسمی</li><li>گام دوم: اجرای دستور نصب و بررسی نسخه</li></ul>
    <blockquote><p id="bq">توجه: این مرحله را حتماً قبل از اجرا انجام دهید.</p></blockquote>
    <table><thead><tr><th id="th">نام</th><th>مقدار</th></tr></thead><tbody><tr><td id="td">نسخه</td><td>2.0.22</td></tr></tbody></table>
    </div></body></html>`
    
    const browser = await chromium.launch()
    const page = await browser.newPage()
    await page.setContent(html)
    const rows = await page.evaluate(() =>
      ["p", "li", "bq", "th", "td"].map((id) => {
        const el = document.getElementById(id)!
        const cs = getComputedStyle(el)
        const box = el.getBoundingClientRect()
        const r = document.createRange()
        const t = el.firstChild!
        r.setStart(t, 0)
        r.setEnd(t, 1)
        const c = r.getBoundingClientRect()
        // first logical character sits at the right edge => RTL paragraph
        const start = box.right - c.right < c.left - box.left ? "right (RTL)" : "left (LTR)"
        return { id, unicodeBidi: cs.unicodeBidi, direction: cs.direction, firstCharStart: start }
      }),
    )
    console.log(`ref=${ref}`)
    console.table(rows)
    await page.screenshot({ path: `/workspace/repro/markdown-${ref.replace(/\W+/g, "_")}.png`, fullPage: true })
    await browser.close()
    const bad = rows.filter((r) => r.id !== "p" && r.firstCharStart.startsWith("left"))
    console.log(bad.length ? `BUG: nested RTL blocks laid out LTR: ${bad.map((r) => r.id).join(", ")}` : "OK")

    Run it: bun repro.ts

  2. argszero commented on Oct 11, 2026

    @argszero
    Contributor

    I'd like to work on this.

    Confirmed the cause on the current dev: the rule in packages/session-ui/src/components/markdown.css is

    [data-component="markdown"] > * {
      unicode-bidi: plaintext;
    }

    so only the container's direct children resolve a direction from their own first strong character. <li>, <td>, <th> and <p> inside <blockquote> keep the UA default unicode-bidi: isolate and inherit whatever the container's dir="auto" resolved to, which is why the scramble only shows up when the reply starts with Latin text (a heading or inline code).

    Plan: extend the plaintext rule to nested block-level elements, keeping code blocks ([data-component="markdown-code"], :not(pre) > code) and Mermaid diagrams explicitly isolated and LTR, and cover it with a component test that measures the computed unicode-bidi on those nested elements so it fails on the unmodified stylesheet.

  3. argszero commented on Oct 11, 2026

    @argszero
    Contributor

    Opened #54636 for this.

    markdown.css extended unicode-bidi: plaintext only to the direct children of the markdown container ([data-component="markdown"] > *), and unicode-bidi does not inherit, so nested blocks (li, td, th, a p inside a blockquote) kept the browser default and inherited the container's dir="auto" direction. The PR extends the plaintext rule to the set the UA stylesheet isolates, in a low-specificity :where() list, and leaves div out so code blocks and Mermaid diagrams keep direction: ltr; unicode-bidi: isolate. component-tests/markdown.spec.ts covers the nested case and the code-block exclusion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions