From de1bd36a47f2e6ade10188b525b4e81781147d3f Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Tue, 4 Aug 2026 21:39:32 +1000
Subject: [PATCH] fix(app): correct RTL layout interactions (#40410)
---
packages/app/src/components/file-tree-v2.tsx | 17 ++--
packages/app/src/components/file-tree.tsx | 4 +-
.../components/settings-v2/settings-v2.css | 8 +-
.../app/src/components/titlebar-tab-nav.css | 23 +++++-
packages/app/src/components/titlebar.tsx | 21 ++++-
.../src/v2/components/session-review-v2.css | 26 +++---
.../src/v2/components/session-review-v2.tsx | 12 ++-
packages/ui/src/components/dropdown-menu.css | 4 +-
packages/ui/src/components/icon.tsx | 9 ++-
packages/ui/src/components/resize-handle.css | 16 +++-
packages/ui/src/components/resize-handle.tsx | 6 +-
packages/ui/src/components/scroll-view.css | 2 +-
packages/ui/src/components/select.css | 8 +-
packages/ui/src/components/switch.css | 4 +
packages/ui/src/components/tabs.css | 80 +++++++++++++------
packages/ui/src/components/tabs.tsx | 1 +
packages/ui/src/components/text-field.css | 4 +-
.../ui/src/v2/components/file-tree-v2.css | 8 +-
packages/ui/src/v2/components/select-v2.css | 21 +++--
packages/ui/src/v2/components/switch-v2.css | 8 ++
.../ui/src/v2/components/text-input-v2.css | 12 +--
21 files changed, 210 insertions(+), 84 deletions(-)
diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx
index ba1a7aaab4..091e489c32 100644
--- a/packages/app/src/components/file-tree-v2.tsx
+++ b/packages/app/src/components/file-tree-v2.tsx
@@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree"
const INDENT_STEP = 16
-function rowPaddingLeft(level: number, type: FileNode["type"]) {
+function rowPaddingStart(level: number, type: FileNode["type"]) {
if (type === "directory") return 8 + level * INDENT_STEP
if (level === 0) return 8
return 8 + level * INDENT_STEP - INDENT_STEP
}
-function guideLineLeft(level: number) {
- return rowPaddingLeft(level, "directory") + 8
+function guideLineStart(level: number) {
+ return rowPaddingStart(level, "directory") + 8
}
export const kindLabel = (kind: Kind) => {
@@ -87,7 +87,7 @@ const FileTreeNodeV2 = (
...local.classList,
[local.class ?? ""]: !!local.class,
}}
- style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
+ style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
@@ -99,7 +99,9 @@ const FileTreeNodeV2 = (
{...rest}
>
{local.children}
- {local.node.name}
+
+ {local.node.name}
+
{(() => {
const value = kind()
if (!value || local.node.type !== "file") return null
@@ -116,7 +118,7 @@ const FileTreeNodeV2 = (
function GuideLines(props: { level: number }) {
return (
- {(_, index) => }
+ {(_, index) => }
)
}
@@ -215,7 +217,6 @@ export default function FileTreeV2(props: {
{
if (!local.draggable) return
diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css
index c4d47344a2..b73269b496 100644
--- a/packages/app/src/components/settings-v2/settings-v2.css
+++ b/packages/app/src/components/settings-v2/settings-v2.css
@@ -287,7 +287,7 @@
}
.settings-v2-provider-env-hint {
- padding-right: 12px;
+ padding-inline-end: 12px;
font-size: 13px;
font-weight: 440;
line-height: 1;
@@ -311,7 +311,7 @@
line-height: 1;
color: var(--v2-text-text-accent);
cursor: pointer;
- text-align: left;
+ text-align: start;
}
.settings-v2-providers-view-all:hover {
@@ -361,13 +361,13 @@
}
.settings-v2-tab-search [data-slot="text-input-v2-input"] {
- padding-right: 28px;
+ padding-inline-end: 28px;
}
.settings-v2-tab-search-clear {
position: absolute;
top: 50%;
- right: 6px;
+ inset-inline-end: 6px;
z-index: 1;
transform: translateY(-50%);
}
diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css
index 13dfbe04fc..93834b5b96 100644
--- a/packages/app/src/components/titlebar-tab-nav.css
+++ b/packages/app/src/components/titlebar-tab-nav.css
@@ -1,7 +1,7 @@
[data-titlebar-tab] [data-slot="tab-close"] {
position: absolute;
top: 4px;
- right: 4px;
+ inset-inline-end: 4px;
display: flex;
height: 20px;
width: 20px;
@@ -52,7 +52,7 @@
content: "";
position: absolute;
top: 8px;
- left: -3.75px;
+ inset-inline-start: -3.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
@@ -86,6 +86,23 @@
);
}
+[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
+ -webkit-mask-image: linear-gradient(
+ to left,
+ black 0,
+ black calc(100% - var(--tab-title-fade-offset) - 16px),
+ transparent calc(100% - var(--tab-title-fade-offset)),
+ transparent 100%
+ );
+ mask-image: linear-gradient(
+ to left,
+ black 0,
+ black calc(100% - var(--tab-title-fade-offset) - 16px),
+ transparent calc(100% - var(--tab-title-fade-offset)),
+ transparent 100%
+ );
+}
+
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
@@ -93,7 +110,7 @@
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
- padding-right: 0;
+ padding-inline-end: 0;
}
[data-titlebar-tab] [data-slot="tab-title"] {
diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx
index f24caeb51b..64df40685f 100644
--- a/packages/app/src/components/titlebar.tsx
+++ b/packages/app/src/components/titlebar.tsx
@@ -1,4 +1,15 @@
-import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
+import {
+ createEffect,
+ createMemo,
+ createResource,
+ createSignal,
+ Match,
+ on,
+ onMount,
+ Show,
+ Switch,
+ untrack,
+} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -42,8 +53,11 @@ export type TitlebarUpdate = {
}
export function useTitlebarRightMount() {
+ const language = useLanguage()
const [mount, setMount] = createSignal
(null)
- onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
+ const sync = () => setMount(document.getElementById("opencode-titlebar-right"))
+ onMount(sync)
+ createEffect(on(language.direction, sync, { defer: true }))
return mount
}
@@ -169,7 +183,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
- "align-self": windows() ? "flex-start" : undefined,
+ // Native Windows caption controls remain on the physical right in both writing directions.
+ "margin-right": windows() ? "auto" : undefined,
}}
data-tauri-drag-region
>
diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css
index 557ef16f6d..ac899be988 100644
--- a/packages/session-ui/src/v2/components/session-review-v2.css
+++ b/packages/session-ui/src/v2/components/session-review-v2.css
@@ -30,19 +30,19 @@
flex-shrink: 0;
min-height: 0;
overflow: hidden;
- border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
+ border-inline-end: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
background: var(--v2-background-bg-base);
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] {
- border-right-width: 0;
+ border-inline-end-width: 0;
}
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar"][data-transition]:not([data-resizing]) {
transition:
width 200ms cubic-bezier(0.22, 1, 0.36, 1),
- border-right-width 200ms cubic-bezier(0.22, 1, 0.36, 1);
+ border-inline-end-width 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-resize"] {
@@ -63,7 +63,8 @@
align-items: center;
justify-content: space-between;
gap: 8px;
- padding: 12px 16px 12px 8px;
+ padding-block: 12px;
+ padding-inline: 8px 16px;
flex-shrink: 0;
}
@@ -79,7 +80,7 @@
[data-component="session-review-v2-sidebar-root"]
[data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) {
- margin-left: 8px;
+ margin-inline-start: 8px;
}
[data-component="session-review-v2-sidebar-root"]
@@ -211,7 +212,7 @@
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
- padding-left: 8px;
+ padding-inline-start: 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
}
@@ -231,7 +232,7 @@
align-items: center;
gap: 8px;
min-width: 0;
- padding-right: 8px;
+ padding-inline-end: 8px;
}
[data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-title"] {
@@ -331,7 +332,7 @@
[data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] {
position: relative;
flex-shrink: 0;
- margin-left: auto;
+ margin-inline-start: auto;
background-color: var(--v2-background-bg-base);
&::before {
@@ -339,17 +340,22 @@
position: absolute;
top: -8px;
bottom: -8px;
- right: 100%;
+ inset-inline-end: 100%;
width: 16px;
z-index: 1;
pointer-events: none;
background: linear-gradient(90deg, transparent, var(--v2-background-bg-base));
}
+ &:dir(rtl)::before {
+ background: linear-gradient(270deg, transparent, var(--v2-background-bg-base));
+ }
+
&::after {
content: "";
position: absolute;
- inset: -8px -16px -8px 0;
+ inset-block: -8px;
+ inset-inline: 0 -16px;
z-index: 0;
pointer-events: none;
background-color: var(--v2-background-bg-base);
diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx
index d2f3b6b8a6..cb0a6bd8cb 100644
--- a/packages/session-ui/src/v2/components/session-review-v2.tsx
+++ b/packages/session-ui/src/v2/components/session-review-v2.tsx
@@ -9,6 +9,7 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { SessionReviewDiffStyle } from "../../components/session-review"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
+import { useLocale } from "@kobalte/core/i18n"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js"
import { getWorkerPool } from "../../pierre/worker"
@@ -149,6 +150,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) {
export function SessionReviewV2(props: SessionReviewV2Props) {
const i18n = useI18n()
+ const locale = useLocale()
createEffect(() => {
getWorkerPool(props.diffStyle)
@@ -175,6 +177,8 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
}
const canCycle = () => props.files.length > 0
+ const previousKey = () => (locale.direction() === "rtl" ? "ArrowRight" : "ArrowLeft")
+ const nextKey = () => (locale.direction() === "rtl" ? "ArrowLeft" : "ArrowRight")
const showCollapsedMeta = () => props.sidebarOpen === false
// Memoize slot getters so Show conditions do not instantiate throwaway elements.
const title = createMemo(() => props.title)
@@ -189,11 +193,11 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
// pane is mounted, but never while typing in an input or comment editor.
makeEventListener(document, "keydown", (event) => {
if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return
- if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
+ if (event.key !== previousKey() && event.key !== nextKey()) return
const target = event.target
if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return
if (!props.hasDiffs || !canCycle()) return
- const file = event.key === "ArrowLeft" ? prev() : next()
+ const file = event.key === previousKey() ? prev() : next()
if (!file) return
event.preventDefault()
cycle(file)
@@ -221,7 +225,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
value={
<>
{i18n.t("ui.sessionReviewV2.previousFile")}
-
+
>
}
>
@@ -241,7 +245,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
value={
<>
{i18n.t("ui.sessionReviewV2.nextFile")}
-
+
>
}
>
diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css
index edc2eee9a4..f8a9fa60ee 100644
--- a/packages/ui/src/components/dropdown-menu.css
+++ b/packages/ui/src/components/dropdown-menu.css
@@ -60,7 +60,7 @@
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"] {
- padding-right: 28px;
+ padding-inline-end: 28px;
}
[data-slot="dropdown-menu-sub-trigger"] {
@@ -76,7 +76,7 @@
width: 16px;
height: 16px;
position: absolute;
- right: 8px;
+ inset-inline-end: 8px;
top: 50%;
transform: translateY(-50%);
}
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index b46cdc833b..a16fad0c32 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -154,7 +154,14 @@ export function Icon(props: IconProps) {