feat(i18n): Hebrew locale + full RTL support (#243)
# feat(i18n): Hebrew locale + full RTL support ## Summary This PR adds full Hebrew (he) locale support to the UI, including a complete translation of all user-facing strings and comprehensive RTL layout support across all components. ## What was done ### Hebrew translation - Full translation of all i18n message files for the `he` locale (17 translation files) - Registered the language in the i18n system and the language picker ### RTL support - Automatic direction detection (`dir="rtl"`) when Hebrew is selected - Replaced physical CSS properties (`left`/`right`) with logical equivalents (`inline-start`/`inline-end`) across the project - Fixed resize direction, file path alignment, and textarea padding - Fixed navigation button positioning in textarea for RTL - Fixed scrollbar direction in RTL - Fixed code block direction and selector alignment - Fixed Monaco editor direction in the file viewer - Auto-detect text direction in reasoning block (`dir="auto"` + `unicode-bidi: plaintext`) ### Adapted components - `session-layout` — sidebar and resize handle - `prompt-input` — text direction and buttons - `message-base` — message blocks and reasoning - `message-timeline` — timeline bar - `right-panel` — right side panel - `tool-call` — tool call display - `settings-screen` — settings page - `selector` — selection component - `instance-shell` — main shell ## New files ``` packages/ui/src/lib/i18n/messages/he/ advancedSettings.ts app.ts commands.ts dialogs.ts filesystem.ts folderSelection.ts index.ts instance.ts loadingScreen.ts logs.ts markdown.ts messaging.ts remoteAccess.ts session.ts settings.ts time.ts toolCall.ts ``` ## Suggested testing - Switch language to Hebrew and verify all strings are translated - Verify RTL layout is correct across all screens (session, settings, file viewer) - Verify that English text inside a reasoning block is displayed LTR - Switch back to English and verify everything returns to LTR --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Shantur Rathore <i@shantur.com>
This commit is contained in:
@@ -45,6 +45,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "zh-Hans", label: "简体中文" },
|
||||
{ value: "he", label: "עברית" },
|
||||
]
|
||||
|
||||
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
|
||||
@@ -341,7 +342,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<div class="absolute top-4 left-6">
|
||||
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
|
||||
<Select<LanguageOption>
|
||||
value={selectedLanguageOption()}
|
||||
onChange={(value) => {
|
||||
@@ -385,7 +386,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</Select.Portal>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
|
||||
@@ -82,7 +82,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="panel-body space-y-3">
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
|
||||
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
{currentInstance().folder}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.project")}
|
||||
</div>
|
||||
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
|
||||
{project().id}
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||
{t("instanceInfo.labels.binaryPath")}
|
||||
</div>
|
||||
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||
{currentInstance().binaryPath}
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,7 +151,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
<div class="space-y-1">
|
||||
<For each={environmentEntries()}>
|
||||
{([key, value]) => (
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
|
||||
{key}
|
||||
</span>
|
||||
|
||||
@@ -81,7 +81,8 @@ interface InstanceShellProps {
|
||||
}
|
||||
|
||||
const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const isRTL = () => locale() === "he"
|
||||
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
|
||||
@@ -371,7 +372,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${sessionSidebarWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderRight: "1px solid var(--border-base)",
|
||||
borderInlineEnd: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -413,7 +414,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="left"
|
||||
anchor={isRTL() ? "right" : "left"}
|
||||
variant="temporary"
|
||||
open={leftOpen()}
|
||||
onClose={closeLeftDrawer}
|
||||
@@ -422,7 +423,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -480,7 +481,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
sx={{
|
||||
width: `${rightDrawerWidth()}px`,
|
||||
flexShrink: 0,
|
||||
borderLeft: "1px solid var(--border-base)",
|
||||
borderInlineStart: "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
@@ -523,7 +524,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
const modalProps = container ? { container: container as Element } : undefined
|
||||
return (
|
||||
<Drawer
|
||||
anchor="right"
|
||||
anchor={isRTL() ? "left" : "right"}
|
||||
variant="temporary"
|
||||
open={rightOpen()}
|
||||
onClose={closeRightDrawer}
|
||||
@@ -532,7 +533,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
"& .MuiDrawer-paper": {
|
||||
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
|
||||
boxSizing: "border-box",
|
||||
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
|
||||
backgroundColor: "var(--surface-secondary)",
|
||||
backgroundImage: "none",
|
||||
color: "var(--text-primary)",
|
||||
@@ -742,7 +743,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
<Kbd shortcut="cmd+shift+p" />
|
||||
</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<div class="connection-status-meta flex items-center gap-3">
|
||||
<Show when={connectionStatus() === "connected"}>
|
||||
<span class="status-indicator connected">
|
||||
|
||||
@@ -249,7 +249,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const mode = activeSplitResize()
|
||||
if (!mode) return
|
||||
event.preventDefault()
|
||||
const delta = event.clientX - splitResizeStartX()
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
@@ -272,7 +273,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
|
||||
const touch = event.touches[0]
|
||||
if (!touch) return
|
||||
event.preventDefault()
|
||||
const delta = touch.clientX - splitResizeStartX()
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
|
||||
const next = clampSplitWidth(splitResizeStartWidth() + delta)
|
||||
if (mode === "changes") setChangesSplitWidth(next)
|
||||
else if (mode === "git-changes") setGitChangesSplitWidth(next)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Component } from "solid-js"
|
||||
|
||||
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
|
||||
|
||||
interface DiffToolbarProps {
|
||||
@@ -14,14 +15,15 @@ interface DiffToolbarProps {
|
||||
}
|
||||
|
||||
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
|
||||
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
|
||||
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
|
||||
|
||||
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
|
||||
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
|
||||
const contextModeTitle = () =>
|
||||
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
|
||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
|
||||
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
|
||||
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
|
||||
|
||||
return (
|
||||
<div class="file-viewer-toolbar">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
|
||||
import { useI18n } from "../../../../../lib/i18n"
|
||||
import OverlayList from "./OverlayList"
|
||||
|
||||
type SplitFilePanelList = {
|
||||
@@ -24,12 +25,13 @@ interface SplitFilePanelProps {
|
||||
}
|
||||
|
||||
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
|
||||
const { t } = useI18n()
|
||||
return (
|
||||
<div class="files-tab-container">
|
||||
<div class="files-tab-header">
|
||||
<div class="files-tab-header-row">
|
||||
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
|
||||
{props.listOpen ? "Hide files" : "Show files"}
|
||||
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
|
||||
</button>
|
||||
|
||||
{props.header}
|
||||
|
||||
@@ -175,7 +175,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
|
||||
title={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
|
||||
disabled={props.browserLoading()}
|
||||
style={{ "margin-left": "auto" }}
|
||||
style={{ "margin-inline-start": "auto" }}
|
||||
onClick={() => props.onRefresh()}
|
||||
>
|
||||
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
|
||||
|
||||
@@ -82,7 +82,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
})
|
||||
|
||||
const emptyViewerMessage = createMemo(() => {
|
||||
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
|
||||
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
|
||||
const currentEntries = entries()
|
||||
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
|
||||
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")
|
||||
|
||||
@@ -46,7 +46,9 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
|
||||
if (!side) return
|
||||
const startWidth = resizeStartWidth()
|
||||
const clamp = side === "left" ? options.clampLeft : options.clampRight
|
||||
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
|
||||
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
|
||||
const delta = isRtl ? -rawDelta : rawDelta
|
||||
const nextWidth = clamp(startWidth + delta)
|
||||
applyDrawerWidth(side, nextWidth)
|
||||
}
|
||||
|
||||
@@ -1531,7 +1531,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
<div class="message-reasoning-expanded">
|
||||
<div class="message-reasoning-body">
|
||||
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
|
||||
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
|
||||
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
|
||||
</span>
|
||||
{currentModelValue() && (
|
||||
<span class="selector-trigger-secondary">
|
||||
<span class="selector-trigger-secondary" dir="ltr">
|
||||
{currentModelValue()!.providerId}/{currentModelValue()!.id}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
setWorktreeSlugForParentSession,
|
||||
} from "../stores/worktrees"
|
||||
import { sessions } from "../stores/sessions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
@@ -25,8 +26,6 @@ type WorktreeOption =
|
||||
| { kind: "action"; key: "__create__"; label: string }
|
||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||
|
||||
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
|
||||
|
||||
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||
// Prevent Select.Item from treating this as a selection.
|
||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||
@@ -71,6 +70,7 @@ interface WorktreeSelectorProps {
|
||||
}
|
||||
|
||||
export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
const { t } = useI18n()
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [createOpen, setCreateOpen] = createSignal(false)
|
||||
const [createSlug, setCreateSlug] = createSignal("")
|
||||
@@ -99,7 +99,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
directory: wt.directory,
|
||||
raw: wt,
|
||||
}))
|
||||
return [CREATE_OPTION, ...mapped]
|
||||
const createOption: WorktreeOption = { kind: "action", key: "__create__", label: t("instanceShell.worktree.create") }
|
||||
return [createOption, ...mapped]
|
||||
})
|
||||
|
||||
const selectedOption = createMemo<WorktreeOption | undefined>(() => {
|
||||
|
||||
Reference in New Issue
Block a user