Files
CodeNomad/packages/ui/src/components/session-rename-dialog.tsx
MusiCode1 09284ee2ce feat(ui): add RTL support for Hebrew/Arabic text (#229)
## 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>
2026-03-22 20:18:24 +00:00

134 lines
4.7 KiB
TypeScript

import { Dialog } from "@kobalte/core/dialog"
import { Component, Show, createEffect, createSignal } from "solid-js"
import { useI18n } from "../lib/i18n"
interface SessionRenameDialogProps {
open: boolean
currentTitle: string
sessionLabel?: string
isSubmitting?: boolean
onRename: (nextTitle: string) => Promise<void> | void
onClose: () => void
}
const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
const { t } = useI18n()
const [title, setTitle] = createSignal("")
const inputId = `session-rename-${Math.random().toString(36).slice(2)}`
let inputRef: HTMLInputElement | undefined
createEffect(() => {
if (!props.open) return
setTitle(props.currentTitle ?? "")
})
createEffect(() => {
if (!props.open) return
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") return
window.requestAnimationFrame(() => {
inputRef?.focus()
inputRef?.select()
})
})
const isSubmitting = () => Boolean(props.isSubmitting)
const isRenameDisabled = () => isSubmitting() || !title().trim()
async function handleRename(event?: Event) {
event?.preventDefault()
if (isRenameDisabled()) return
await props.onRename(title().trim())
}
const description = () => {
if (props.sessionLabel && props.sessionLabel.trim()) {
return t("sessionRenameDialog.description.withLabel", { label: props.sessionLabel })
}
return t("sessionRenameDialog.description.default")
}
return (
<Dialog
open={props.open}
onOpenChange={(open) => {
if (!open && !isSubmitting()) {
props.onClose()
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6" tabIndex={-1}>
<Dialog.Title class="text-lg font-semibold text-primary">{t("sessionRenameDialog.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
{description()}
</Dialog.Description>
<form class="mt-4 space-y-4" onSubmit={handleRename}>
<div class="space-y-2">
<label class="text-sm font-medium text-secondary" for={inputId}>
{t("sessionRenameDialog.input.label")}
</label>
<input
id={inputId}
ref={(element) => {
inputRef = element
}}
type="text"
dir="auto"
value={title()}
onInput={(event) => setTitle(event.currentTarget.value)}
placeholder={t("sessionRenameDialog.input.placeholder")}
class="w-full px-3 py-2 text-sm bg-surface-base border border-base rounded text-primary focus-ring-accent"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="button-tertiary"
onClick={() => {
if (!isSubmitting()) {
props.onClose()
}
}}
disabled={isSubmitting()}
>
{t("sessionRenameDialog.actions.cancel")}
</button>
<button
type="submit"
class="button-primary flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
disabled={isRenameDisabled()}
>
<Show
when={!isSubmitting()}
fallback={
<>
<svg class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>{t("sessionRenameDialog.actions.renaming")}</span>
</>
}
>
{t("sessionRenameDialog.actions.rename")}
</Show>
</button>
</div>
</form>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}
export default SessionRenameDialog