## What and why CodeNomad had no RTL (right-to-left) support, so users writing in Hebrew or Arabic would see their messages displayed left-to-right — misaligned text, broken reading flow, wrong punctuation placement. This PR adds automatic direction detection to all elements that display user or model text. The browser detects direction from the first strong character in each text block: Hebrew/Arabic → RTL, Latin/code → LTR. No configuration needed — it just works per message, per paragraph. ## Technical notes The natural fix is `dir="auto"` on the containing elements. However, Chromium does not propagate direction detection from a parent `<div>` into its `<p>` children — so Hebrew inside `<p>` rendered via `innerHTML` (as markdown is) was still detected as LTR. The fix is to apply `unicode-bidi: plaintext` via CSS directly on the block-level elements (`p`, `li`, headings, etc.), which has the same auto-detection semantics but applies per element. ## Summary - Add `dir="auto"` to all elements containing user-generated or model-generated text (message content, prompt input, session names, tool outputs) so the browser auto-detects text direction - Add `unicode-bidi: plaintext` via CSS to markdown block elements (`p`, `li`, headings, `blockquote`, `td`/`th`) to fix per-paragraph RTL detection in Chromium (where `dir="auto"` on a parent div does not recurse into block children) - Convert physical CSS properties to logical equivalents in `markdown.css`: `border-left` → `border-inline-start`, `padding-left` → `padding-inline-start`, `text-align: left` → `text-align: start`, `margin-left` → `margin-inline-start` ## Affected components - `markdown.tsx` — main markdown renderer - `message-part.tsx` — text part wrapper and plain-text fallback - `message-item.tsx` — message body and error blocks - `prompt-input.tsx` — user input textarea - `session-list.tsx` — session titles in sidebar - `session-rename-dialog.tsx` — session rename input - `instance-welcome-view.tsx` — Resume Session dialog - `tool-call/markdown-render.tsx` — tool output markdown fallback - `tool-call/ansi-render.tsx` — ANSI output - `tool-call/diagnostics-section.tsx` — diagnostic messages ## Test plan - [ ] Send a Hebrew-only message → text right-aligned - [ ] Send a mixed Hebrew + English message → correct per-paragraph direction - [ ] Message containing a code block → code stays LTR - [ ] Type Hebrew in the prompt textarea → input flows right-to-left - [ ] Hebrew session name in sidebar → right-aligned - [ ] Hebrew session name in Resume Session dialog → right-aligned 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
256 lines
7.2 KiB
TypeScript
256 lines
7.2 KiB
TypeScript
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
|
import type { TextPart, RenderCache } from "../types/message"
|
|
import { getLogger } from "../lib/logger"
|
|
import { copyToClipboard } from "../lib/clipboard"
|
|
import { useI18n } from "../lib/i18n"
|
|
|
|
const log = getLogger("session")
|
|
|
|
type MarkdownModule = typeof import("../lib/markdown")
|
|
|
|
let markdownModulePromise: Promise<MarkdownModule> | null = null
|
|
|
|
function loadMarkdownModule(): Promise<MarkdownModule> {
|
|
if (!markdownModulePromise) {
|
|
markdownModulePromise = import("../lib/markdown").catch((error) => {
|
|
markdownModulePromise = null
|
|
throw error
|
|
})
|
|
}
|
|
return markdownModulePromise
|
|
}
|
|
|
|
function hashText(value: string): string {
|
|
let hash = 2166136261
|
|
for (let index = 0; index < value.length; index++) {
|
|
hash ^= value.charCodeAt(index)
|
|
hash = Math.imul(hash, 16777619)
|
|
}
|
|
return (hash >>> 0).toString(16)
|
|
}
|
|
|
|
function resolvePartVersion(part: TextPart, text: string): string {
|
|
if (typeof part.version === "number") {
|
|
return String(part.version)
|
|
}
|
|
return `text-${hashText(text)}`
|
|
}
|
|
|
|
function resolvePartCacheId(part: TextPart, text: string): string {
|
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : ""
|
|
if (partId) {
|
|
return partId
|
|
}
|
|
|
|
return `anonymous:${hashText(text)}`
|
|
}
|
|
|
|
function decodeHtmlEntitiesLocally(content: string): string {
|
|
if (!content.includes("&") || typeof document === "undefined") {
|
|
return content
|
|
}
|
|
|
|
const textarea = document.createElement("textarea")
|
|
textarea.innerHTML = content
|
|
return textarea.value
|
|
}
|
|
|
|
function escapeHtml(content: string): string {
|
|
const map: Record<string, string> = {
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
}
|
|
|
|
return content.replace(/[&<>"']/g, (match) => map[match] ?? match)
|
|
}
|
|
|
|
function renderFallbackHtml(content: string): string {
|
|
if (!content) {
|
|
return ""
|
|
}
|
|
|
|
return escapeHtml(content).replace(/\n/g, "<br />")
|
|
}
|
|
|
|
interface MarkdownProps {
|
|
part: TextPart
|
|
instanceId?: string
|
|
sessionId?: string
|
|
isDark?: boolean
|
|
size?: "base" | "sm" | "tight"
|
|
disableHighlight?: boolean
|
|
onRendered?: () => void
|
|
}
|
|
|
|
export function Markdown(props: MarkdownProps) {
|
|
const { t } = useI18n()
|
|
const [html, setHtml] = createSignal("")
|
|
let containerRef: HTMLDivElement | undefined
|
|
let latestRequestKey = ""
|
|
let cleanupLanguageListener: (() => void) | undefined
|
|
|
|
const notifyRendered = () => {
|
|
Promise.resolve().then(() => props.onRendered?.())
|
|
}
|
|
|
|
const resolved = createMemo(() => {
|
|
const part = props.part
|
|
const rawText = typeof part.text === "string" ? part.text : ""
|
|
const text = decodeHtmlEntitiesLocally(rawText)
|
|
const themeKey = Boolean(props.isDark) ? "dark" : "light"
|
|
const highlightEnabled = !props.disableHighlight
|
|
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
|
|
const cacheId = resolvePartCacheId(part, text)
|
|
const version = resolvePartVersion(part, text)
|
|
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
|
|
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
|
|
})
|
|
|
|
const cacheHandle = useGlobalCache({
|
|
instanceId: () => props.instanceId,
|
|
sessionId: () => props.sessionId,
|
|
scope: "markdown",
|
|
cacheId: () => {
|
|
const { cacheId, themeKey, highlightEnabled } = resolved()
|
|
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
|
|
},
|
|
version: () => resolved().version,
|
|
})
|
|
|
|
const commitCacheEntry = (snapshot: ReturnType<typeof resolved>, renderedHtml: string) => {
|
|
const cacheEntry: RenderCache = {
|
|
text: snapshot.text,
|
|
html: renderedHtml,
|
|
theme: snapshot.themeKey,
|
|
mode: snapshot.version,
|
|
}
|
|
setHtml(renderedHtml)
|
|
cacheHandle.set(cacheEntry)
|
|
notifyRendered()
|
|
}
|
|
|
|
const renderSnapshot = async (snapshot: ReturnType<typeof resolved>) => {
|
|
const markdown = await loadMarkdownModule()
|
|
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
|
|
const rendered = await markdown.renderMarkdown(snapshot.text, {
|
|
suppressHighlight: !snapshot.highlightEnabled,
|
|
})
|
|
|
|
if (latestRequestKey === snapshot.requestKey) {
|
|
commitCacheEntry(snapshot, rendered)
|
|
}
|
|
}
|
|
|
|
createEffect(() => {
|
|
const snapshot = resolved()
|
|
latestRequestKey = snapshot.requestKey
|
|
|
|
const cacheMatches = (cache: RenderCache | undefined) => {
|
|
if (!cache) return false
|
|
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
|
|
}
|
|
|
|
const localCache = snapshot.part.renderCache
|
|
if (localCache && cacheMatches(localCache)) {
|
|
setHtml(localCache.html)
|
|
notifyRendered()
|
|
return
|
|
}
|
|
|
|
const globalCache = cacheHandle.get<RenderCache>()
|
|
if (globalCache && cacheMatches(globalCache)) {
|
|
setHtml(globalCache.html)
|
|
notifyRendered()
|
|
return
|
|
}
|
|
|
|
setHtml(renderFallbackHtml(snapshot.text))
|
|
notifyRendered()
|
|
|
|
void renderSnapshot(snapshot).catch((error) => {
|
|
log.error("Failed to render markdown:", error)
|
|
if (latestRequestKey === snapshot.requestKey) {
|
|
commitCacheEntry(snapshot, renderFallbackHtml(snapshot.text))
|
|
}
|
|
})
|
|
})
|
|
|
|
onMount(() => {
|
|
const handleClick = async (event: Event) => {
|
|
const target = event.target as HTMLElement
|
|
const copyButton = target.closest(".code-block-copy") as HTMLButtonElement
|
|
|
|
if (!copyButton) {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
const code = copyButton.getAttribute("data-code")
|
|
if (!code) {
|
|
return
|
|
}
|
|
|
|
const decodedCode = decodeURIComponent(code)
|
|
const success = await copyToClipboard(decodedCode)
|
|
const copyText = copyButton.querySelector(".copy-text")
|
|
if (!copyText) {
|
|
return
|
|
}
|
|
|
|
copyText.textContent = success ? t("markdown.codeBlock.copy.copied") : t("markdown.codeBlock.copy.failed")
|
|
setTimeout(() => {
|
|
copyText.textContent = t("markdown.codeBlock.copy.label")
|
|
}, 2000)
|
|
}
|
|
|
|
containerRef?.addEventListener("click", handleClick)
|
|
|
|
let disposed = false
|
|
void loadMarkdownModule()
|
|
.then((markdown) => {
|
|
if (disposed) {
|
|
return
|
|
}
|
|
|
|
cleanupLanguageListener = markdown.onLanguagesLoaded(() => {
|
|
const snapshot = resolved()
|
|
if (!snapshot.highlightEnabled) {
|
|
return
|
|
}
|
|
|
|
latestRequestKey = snapshot.requestKey
|
|
void renderSnapshot(snapshot).catch((error) => {
|
|
log.error("Failed to re-render markdown after language load:", error)
|
|
})
|
|
})
|
|
})
|
|
.catch((error) => {
|
|
log.error("Failed to load markdown module:", error)
|
|
})
|
|
|
|
onCleanup(() => {
|
|
disposed = true
|
|
containerRef?.removeEventListener("click", handleClick)
|
|
cleanupLanguageListener?.()
|
|
cleanupLanguageListener = undefined
|
|
})
|
|
})
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
class="markdown-body"
|
|
dir="auto"
|
|
data-view="markdown"
|
|
data-part-id={resolved().partId}
|
|
data-markdown-theme={resolved().themeKey}
|
|
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
|
|
innerHTML={html()}
|
|
/>
|
|
)
|
|
}
|