diff --git a/ui/index.html b/ui/index.html
index a36c6850158..dc03f49115c 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -8,59 +8,6 @@
-
diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css
index 6d943253804..1984813f41f 100644
--- a/ui/src/styles/layout.mobile.css
+++ b/ui/src/styles/layout.mobile.css
@@ -4,6 +4,15 @@
/* Tablet and smaller: switch the left nav to a slide-over drawer. */
@media (max-width: 1100px) {
+ html[data-ios-mobile]
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="button"]):not(
+ [type="submit"]
+ ):not([type="reset"]):not([type="file"]):not([type="image"]):not([type="hidden"]),
+ html[data-ios-mobile] textarea,
+ html[data-ios-mobile] select {
+ font-size: 16px !important;
+ }
+
.shell,
.shell--nav-collapsed {
grid-template-columns: minmax(0, 1fr);
@@ -223,6 +232,55 @@
/* Mobile-specific styles */
@media (max-width: 768px) {
+ html,
+ body,
+ openclaw-app {
+ height: 100%;
+ }
+
+ html[data-ios-shell-lock],
+ body[data-ios-shell-lock] {
+ height: var(--mobile-layout-height, var(--mobile-viewport-height, 100svh));
+ overflow: hidden;
+ }
+
+ openclaw-app[data-ios-mobile] {
+ height: var(--mobile-layout-height, var(--mobile-viewport-height, 100svh));
+ min-height: var(--mobile-layout-height, var(--mobile-viewport-height, 100svh));
+ }
+
+ openclaw-app[data-ios-shell-lock] {
+ overflow: hidden;
+ }
+
+ openclaw-app[data-ios-shell-lock][data-ios-keyboard-open] .content--chat .agent-chat__input {
+ position: fixed;
+ left: 8px;
+ right: 8px;
+ bottom: max(8px, env(safe-area-inset-bottom, 0px));
+ margin: 0;
+ z-index: 60;
+ }
+
+ openclaw-app[data-ios-shell-lock][data-ios-keyboard-open] .content--chat .chat-thread {
+ padding-bottom: calc(var(--mobile-chat-input-height, 112px) + 16px);
+ scroll-padding-bottom: calc(var(--mobile-chat-input-height, 112px) + 16px);
+ }
+
+ openclaw-app[data-ios-mobile] .shell,
+ openclaw-app[data-ios-mobile] .shell--chat,
+ openclaw-app[data-ios-mobile] .shell--nav-collapsed,
+ openclaw-app[data-ios-mobile] .shell--chat-focus {
+ height: var(--mobile-layout-height, var(--mobile-viewport-height, 100svh));
+ min-height: var(--mobile-layout-height, var(--mobile-viewport-height, 100svh));
+ }
+
+ .shell,
+ .shell--nav-collapsed,
+ .shell--chat-focus {
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
.shell {
--shell-pad: 8px;
--shell-gap: 8px;
@@ -235,21 +293,43 @@
}
.topnav-shell {
- flex-wrap: wrap;
- gap: 10px;
+ flex-wrap: nowrap;
+ gap: 6px;
}
.topnav-shell__actions {
min-width: 0;
- flex: 1 1 auto;
- justify-content: space-between;
- gap: 10px;
- align-items: stretch;
+ order: 2;
+ flex: 1 1 0;
+ justify-content: flex-start;
+ gap: 6px;
+ align-items: center;
}
.topnav-shell__content {
- display: none;
- width: 100%;
+ order: 3;
+ width: auto;
+ min-width: 0;
+ flex: 0 0 auto;
+ }
+
+ .topnav-shell .dashboard-header {
+ justify-content: flex-end;
+ gap: 4px;
+ }
+
+ .topnav-shell .dashboard-header__breadcrumb {
+ gap: 4px;
+ font-size: 12px;
+ }
+
+ .topnav-shell .dashboard-header__breadcrumb-link {
+ white-space: nowrap;
+ }
+
+ .topnav-shell .dashboard-header__breadcrumb-current {
+ min-width: 0;
+ max-width: none;
}
.topbar-nav-toggle {
@@ -268,6 +348,7 @@
}
.topbar-status {
+ flex-shrink: 0;
gap: 6px;
width: auto;
flex-wrap: nowrap;
@@ -275,7 +356,20 @@
.topbar-search {
min-width: 0;
- flex: 1;
+ flex: 1 1 0;
+ max-width: none;
+ padding-inline: 10px;
+ gap: 6px;
+ }
+
+ .topbar-search__label {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .topbar-search__kbd {
+ display: none;
}
.topbar-theme-mode {
@@ -330,76 +424,99 @@
display: none;
}
- /* Hide the entire content-header on mobile chat — controls are in mobile gear menu */
.content--chat .content-header {
- display: none;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 8px;
}
.content--chat {
gap: 2px;
}
- /* Show the mobile gear toggle (lives in topbar now) */
- .chat-mobile-controls-wrapper {
- display: flex;
- position: relative;
+ .content--chat .content-header > div:first-child {
+ min-width: 0;
}
- .chat-mobile-controls-wrapper .chat-controls-mobile-toggle {
- display: flex;
+ .content--chat .page-meta {
+ width: auto;
+ justify-self: end;
}
- /* The dropdown panel — anchored below the gear in topbar */
- .chat-mobile-controls-wrapper .chat-controls-dropdown {
+ .content--chat .chat-controls__session-row {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ min-width: 0;
+ }
+
+ .content--chat .chat-controls__session,
+ .content--chat .chat-controls__model {
+ min-width: 0;
+ max-width: none;
+ }
+
+ .content--chat .chat-controls__session {
+ flex: 2 1 0;
+ }
+
+ .content--chat .chat-controls__model {
+ flex: 3 1 0;
+ }
+
+ .content--chat .chat-controls {
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ flex-wrap: nowrap;
+ gap: 4px;
+ min-width: 0;
+ }
+
+ .content--chat .chat-controls__session select,
+ .content--chat .chat-controls__model select {
+ width: 100%;
+ font-size: 16px;
+ padding: 6px 8px;
+ }
+
+ .content--chat .btn--icon {
+ min-width: 30px;
+ height: 30px;
+ padding: 5px !important;
+ }
+
+ .content--chat .btn--icon svg {
+ width: 14px;
+ height: 14px;
+ }
+
+ .content--chat .chat-controls__separator {
display: none;
- position: absolute;
- top: 100%;
- right: 0;
- z-index: 100;
- background: var(--card, #161b22);
- border: 1px solid var(--border, #30363d);
- border-radius: 10px;
- padding: 8px;
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
- flex-direction: column;
- gap: 4px;
- min-width: 220px;
}
- .chat-mobile-controls-wrapper .chat-controls-dropdown.open {
- display: flex;
+ .content--chat .content-header,
+ .content--chat .chat,
+ .content--chat .chat-split-container,
+ .content--chat .chat-main {
+ min-height: 0;
}
- .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls {
- display: flex;
- flex-direction: column;
- gap: 4px;
- width: 100%;
+ .content--chat .card.chat {
+ padding-top: 0;
}
- .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session {
- min-width: unset;
- max-width: unset;
- width: 100%;
+ .content--chat .chat-thread {
+ padding-bottom: 8px;
}
- .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session select {
- width: 100%;
- font-size: 14px;
- padding: 10px 12px;
+ .content--chat .agent-chat__input {
+ margin: 0 8px calc(10px + env(safe-area-inset-bottom, 0px));
}
- .chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking {
- display: flex;
- flex-direction: row;
- gap: 6px;
- padding: 4px 0;
- justify-content: center;
- }
-
- .chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon {
- min-width: 44px;
- height: 44px;
+ .content--chat .agent-chat__input > textarea {
+ font-size: 16px;
}
.content {
padding: 4px 4px 16px;
diff --git a/ui/src/ui/app-mobile-viewport.test.ts b/ui/src/ui/app-mobile-viewport.test.ts
new file mode 100644
index 00000000000..a5e09cf1eaf
--- /dev/null
+++ b/ui/src/ui/app-mobile-viewport.test.ts
@@ -0,0 +1,270 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { attachMobileViewportFixes } from "./app-mobile-viewport.ts";
+
+class MockVisualViewport extends EventTarget {
+ width: number;
+ height: number;
+
+ constructor(width: number, height: number) {
+ super();
+ this.width = width;
+ this.height = height;
+ }
+
+ setSize(width: number, height: number) {
+ this.width = width;
+ this.height = height;
+ }
+}
+
+class MockResizeObserver {
+ observe() {}
+ disconnect() {}
+}
+
+const originalVisualViewport = Object.getOwnPropertyDescriptor(window, "visualViewport");
+const originalUserAgent = Object.getOwnPropertyDescriptor(window.navigator, "userAgent");
+const originalPlatform = Object.getOwnPropertyDescriptor(window.navigator, "platform");
+const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(window.navigator, "maxTouchPoints");
+const originalResizeObserver = globalThis.ResizeObserver;
+
+function restoreProperty(target: object, key: string, descriptor?: PropertyDescriptor) {
+ if (descriptor) {
+ Object.defineProperty(target, key, descriptor);
+ return;
+ }
+ Reflect.deleteProperty(target, key);
+}
+
+function createHost() {
+ const host = document.createElement("openclaw-app") as HTMLElement & {
+ updateComplete: Promise;
+ };
+ host.updateComplete = Promise.resolve();
+ host.innerHTML = `
+
+ `;
+ const input = host.querySelector(".agent-chat__input");
+ const textarea = host.querySelector("textarea");
+ if (!input || !textarea) {
+ throw new Error("failed to create mobile viewport test host");
+ }
+ Object.defineProperty(input, "getBoundingClientRect", {
+ configurable: true,
+ value: () =>
+ ({
+ width: 320,
+ height: 72,
+ top: 0,
+ left: 0,
+ right: 320,
+ bottom: 72,
+ x: 0,
+ y: 0,
+ toJSON: () => ({}),
+ }) satisfies Partial,
+ });
+ document.body.append(host);
+ return { host, textarea };
+}
+
+async function flushViewportWork() {
+ await Promise.resolve();
+ vi.runAllTimers();
+ await Promise.resolve();
+}
+
+function createBlurTarget() {
+ const button = document.createElement("button");
+ button.type = "button";
+ document.body.append(button);
+ return button;
+}
+
+describe("attachMobileViewportFixes", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ Object.defineProperty(window.navigator, "userAgent", {
+ configurable: true,
+ value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
+ });
+ Object.defineProperty(window.navigator, "platform", {
+ configurable: true,
+ value: "iPhone",
+ });
+ Object.defineProperty(window.navigator, "maxTouchPoints", {
+ configurable: true,
+ value: 5,
+ });
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: new MockVisualViewport(400, 800),
+ });
+ Object.defineProperty(window, "scrollTo", {
+ configurable: true,
+ value: vi.fn(),
+ });
+ vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
+ cb(0);
+ return 1;
+ });
+ globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
+ });
+
+ afterEach(() => {
+ vi.runOnlyPendingTimers();
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ document.documentElement.removeAttribute("data-ios-mobile");
+ document.documentElement.removeAttribute("data-ios-keyboard-open");
+ document.documentElement.removeAttribute("data-ios-shell-lock");
+ document.body.removeAttribute("data-ios-mobile");
+ document.body.removeAttribute("data-ios-keyboard-open");
+ document.body.removeAttribute("data-ios-shell-lock");
+ document.documentElement.style.removeProperty("--mobile-layout-height");
+ document.documentElement.style.removeProperty("--mobile-viewport-height");
+ document.documentElement.style.removeProperty("--mobile-chat-input-height");
+ restoreProperty(window, "visualViewport", originalVisualViewport);
+ restoreProperty(window.navigator, "userAgent", originalUserAgent);
+ restoreProperty(window.navigator, "platform", originalPlatform);
+ restoreProperty(window.navigator, "maxTouchPoints", originalMaxTouchPoints);
+ globalThis.ResizeObserver = originalResizeObserver;
+ });
+
+ it("does not treat an unfocused viewport shrink as keyboard open", async () => {
+ const { host } = createHost();
+ const controller = attachMobileViewportFixes(host);
+ const viewport = window.visualViewport as unknown as MockVisualViewport;
+ const blurTarget = createBlurTarget();
+
+ blurTarget.focus();
+ expect(document.activeElement).toBe(blurTarget);
+
+ viewport.setSize(400, 620);
+ viewport.dispatchEvent(new Event("resize"));
+ await flushViewportWork();
+
+ expect(document.documentElement.hasAttribute("data-ios-keyboard-open")).toBe(false);
+ expect(document.documentElement.style.getPropertyValue("--mobile-layout-height")).toBe("620px");
+
+ controller.cleanup();
+ });
+
+ it("does not enable the iOS mobile layout gate without visualViewport support", () => {
+ const { host } = createHost();
+ restoreProperty(window, "visualViewport", undefined);
+
+ const controller = attachMobileViewportFixes(host);
+
+ expect(document.documentElement.hasAttribute("data-ios-mobile")).toBe(false);
+ expect(document.body.hasAttribute("data-ios-mobile")).toBe(false);
+ expect(host.hasAttribute("data-ios-mobile")).toBe(false);
+
+ controller.cleanup();
+ });
+
+ it("preserves the pre-keyboard layout height while a text field is focused", async () => {
+ const { host, textarea } = createHost();
+ const controller = attachMobileViewportFixes(host);
+ const viewport = window.visualViewport as unknown as MockVisualViewport;
+
+ textarea.focus();
+ expect(document.activeElement).toBe(textarea);
+ viewport.setSize(400, 560);
+ viewport.dispatchEvent(new Event("resize"));
+ await flushViewportWork();
+
+ expect(document.documentElement.hasAttribute("data-ios-keyboard-open")).toBe(true);
+ expect(document.documentElement.style.getPropertyValue("--mobile-layout-height")).toBe("800px");
+
+ controller.cleanup();
+ });
+
+ it("keeps the pre-keyboard baseline stable across incremental keyboard resizes", async () => {
+ const { host, textarea } = createHost();
+ const controller = attachMobileViewportFixes(host);
+ const viewport = window.visualViewport as unknown as MockVisualViewport;
+
+ textarea.focus();
+ expect(document.activeElement).toBe(textarea);
+
+ viewport.setSize(400, 730);
+ viewport.dispatchEvent(new Event("resize"));
+ await flushViewportWork();
+
+ expect(document.documentElement.hasAttribute("data-ios-keyboard-open")).toBe(false);
+ expect(document.documentElement.style.getPropertyValue("--mobile-layout-height")).toBe("730px");
+
+ viewport.setSize(400, 560);
+ viewport.dispatchEvent(new Event("resize"));
+ await flushViewportWork();
+
+ expect(document.documentElement.hasAttribute("data-ios-keyboard-open")).toBe(true);
+ expect(document.documentElement.style.getPropertyValue("--mobile-layout-height")).toBe("800px");
+
+ controller.cleanup();
+ });
+
+ it("resets the keyboard baseline after orientation changes", async () => {
+ const { host, textarea } = createHost();
+ const controller = attachMobileViewportFixes(host);
+ const viewport = window.visualViewport as unknown as MockVisualViewport;
+ const blurTarget = createBlurTarget();
+
+ textarea.focus();
+ expect(document.activeElement).toBe(textarea);
+ viewport.setSize(400, 560);
+ viewport.dispatchEvent(new Event("resize"));
+ await flushViewportWork();
+ expect(document.documentElement.hasAttribute("data-ios-keyboard-open")).toBe(true);
+
+ blurTarget.focus();
+ expect(document.activeElement).toBe(blurTarget);
+ viewport.setSize(800, 360);
+ window.dispatchEvent(new Event("orientationchange"));
+ await flushViewportWork();
+
+ expect(document.documentElement.hasAttribute("data-ios-keyboard-open")).toBe(false);
+ expect(document.documentElement.style.getPropertyValue("--mobile-layout-height")).toBe("360px");
+
+ controller.cleanup();
+ });
+
+ it("does not snap document scroll when the shell lock is inactive", async () => {
+ const { host } = createHost();
+ const controller = attachMobileViewportFixes(host);
+ const viewport = window.visualViewport as unknown as MockVisualViewport;
+ const scrollingElement = document.scrollingElement ?? document.documentElement;
+
+ scrollingElement.scrollTop = 240;
+ viewport.dispatchEvent(new Event("scroll"));
+ await flushViewportWork();
+
+ expect(window.scrollTo).not.toHaveBeenCalled();
+ expect(scrollingElement.scrollTop).toBe(240);
+
+ controller.cleanup();
+ });
+
+ it("restores document scroll after iOS viewport changes while the shell is locked", async () => {
+ const { host } = createHost();
+ const controller = attachMobileViewportFixes(host);
+ const viewport = window.visualViewport as unknown as MockVisualViewport;
+ const scrollingElement = document.scrollingElement ?? document.documentElement;
+
+ controller.setShellLocked(true);
+ scrollingElement.scrollTop = 240;
+ viewport.dispatchEvent(new Event("scroll"));
+ await flushViewportWork();
+
+ expect(window.scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "auto" });
+ expect(scrollingElement.scrollTop).toBe(0);
+
+ controller.cleanup();
+ });
+});
diff --git a/ui/src/ui/app-mobile-viewport.ts b/ui/src/ui/app-mobile-viewport.ts
new file mode 100644
index 00000000000..3f197b8dd76
--- /dev/null
+++ b/ui/src/ui/app-mobile-viewport.ts
@@ -0,0 +1,221 @@
+type MobileViewportHost = {
+ updateComplete: Promise;
+};
+
+type MobileViewportController = {
+ cleanup: () => void;
+ setShellLocked: (locked: boolean) => void;
+};
+
+type ViewportUpdateOptions = {
+ resetBaseline?: boolean;
+};
+
+const IOS_KEYBOARD_DELTA_THRESHOLD = 120;
+
+function isIosDevice(): boolean {
+ const { userAgent, platform, maxTouchPoints } = navigator;
+ return /iPad|iPhone|iPod/.test(userAgent) || (platform === "MacIntel" && maxTouchPoints > 1);
+}
+
+function isTextEntryElement(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+ if (target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {
+ return true;
+ }
+ if (target instanceof HTMLInputElement) {
+ return target.type !== "checkbox" && target.type !== "radio" && target.type !== "button";
+ }
+ return Boolean(target.isContentEditable);
+}
+
+export function attachMobileViewportFixes(
+ host: MobileViewportHost & HTMLElement,
+): MobileViewportController {
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ return { cleanup: () => {}, setShellLocked: () => {} };
+ }
+
+ if (!isIosDevice()) {
+ return { cleanup: () => {}, setShellLocked: () => {} };
+ }
+
+ const root = document.documentElement;
+ const body = document.body;
+ const setIosMobile = (active: boolean) => {
+ root.toggleAttribute("data-ios-mobile", active);
+ body.toggleAttribute("data-ios-mobile", active);
+ host.toggleAttribute("data-ios-mobile", active);
+ };
+
+ const viewport = window.visualViewport;
+ if (!viewport) {
+ return {
+ cleanup: () => {},
+ setShellLocked: () => {},
+ };
+ }
+ setIosMobile(true);
+
+ let restoreTimer: number | null = null;
+ let focusTimer: number | null = null;
+ let inputObserver: ResizeObserver | null = null;
+ let observedInput: HTMLElement | null = null;
+ let baselineViewportHeight = Math.round(viewport.height);
+ let shellLocked = false;
+
+ const setShellLocked = (locked: boolean) => {
+ shellLocked = locked;
+ root.toggleAttribute("data-ios-shell-lock", locked);
+ body.toggleAttribute("data-ios-shell-lock", locked);
+ host.toggleAttribute("data-ios-shell-lock", locked);
+ };
+
+ const setKeyboardOpen = (open: boolean) => {
+ root.toggleAttribute("data-ios-keyboard-open", open);
+ body.toggleAttribute("data-ios-keyboard-open", open);
+ host.toggleAttribute("data-ios-keyboard-open", open);
+ };
+
+ const updateViewportHeight = ({ resetBaseline = false }: ViewportUpdateOptions = {}) => {
+ const height = Math.round(viewport.height);
+ const textEntryFocused = isTextEntryElement(document.activeElement);
+ if (resetBaseline) {
+ baselineViewportHeight = height;
+ } else if (!textEntryFocused) {
+ baselineViewportHeight = height;
+ } else if (height > baselineViewportHeight) {
+ baselineViewportHeight = height;
+ }
+ // Freeze the pre-keyboard height only while a text control is focused and
+ // the visual viewport has actually shrunk enough to indicate the keyboard.
+ const keyboardOpen =
+ textEntryFocused && baselineViewportHeight - height > IOS_KEYBOARD_DELTA_THRESHOLD;
+ const layoutHeight = keyboardOpen ? baselineViewportHeight : height;
+ root.style.setProperty("--mobile-viewport-height", `${height}px`);
+ root.style.setProperty("--mobile-layout-height", `${layoutHeight}px`);
+ setKeyboardOpen(keyboardOpen);
+ };
+
+ const syncChatInputMetrics = () => {
+ const input = host.querySelector(".content--chat .agent-chat__input");
+ if (!input) {
+ inputObserver?.disconnect();
+ inputObserver = null;
+ observedInput = null;
+ root.style.removeProperty("--mobile-chat-input-height");
+ return;
+ }
+ const measure = () => {
+ root.style.setProperty(
+ "--mobile-chat-input-height",
+ `${Math.ceil(input.getBoundingClientRect().height)}px`,
+ );
+ };
+ measure();
+ if (observedInput === input) {
+ return;
+ }
+ inputObserver?.disconnect();
+ observedInput = input;
+ if (typeof ResizeObserver !== "undefined") {
+ inputObserver = new ResizeObserver(() => measure());
+ inputObserver.observe(input);
+ }
+ };
+
+ const restoreDocumentScroll = () => {
+ if (!shellLocked) {
+ return;
+ }
+ if (restoreTimer != null) {
+ clearTimeout(restoreTimer);
+ }
+ restoreTimer = window.setTimeout(() => {
+ restoreTimer = null;
+ if (!shellLocked) {
+ return;
+ }
+ if (isTextEntryElement(document.activeElement)) {
+ return;
+ }
+ void host.updateComplete.then(() => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ window.scrollTo({ top: 0, left: 0, behavior: "auto" });
+ const scrollingElement = document.scrollingElement ?? document.documentElement;
+ scrollingElement.scrollTop = 0;
+ updateViewportHeight();
+ syncChatInputMetrics();
+ });
+ });
+ });
+ }, 120);
+ };
+
+ const handleViewportChange = () => {
+ updateViewportHeight();
+ restoreDocumentScroll();
+ };
+
+ const handleOrientationChange = () => {
+ updateViewportHeight({ resetBaseline: true });
+ restoreDocumentScroll();
+ };
+
+ const handleFocusIn = (event: FocusEvent) => {
+ if (!isTextEntryElement(event.target)) {
+ return;
+ }
+ updateViewportHeight({ resetBaseline: true });
+ if (focusTimer != null) {
+ clearTimeout(focusTimer);
+ }
+ focusTimer = window.setTimeout(() => {
+ focusTimer = null;
+ updateViewportHeight();
+ syncChatInputMetrics();
+ restoreDocumentScroll();
+ }, 80);
+ };
+
+ const handleFocusOut = () => {
+ restoreDocumentScroll();
+ };
+
+ updateViewportHeight();
+ void host.updateComplete.then(() => syncChatInputMetrics());
+ viewport.addEventListener("resize", handleViewportChange);
+ viewport.addEventListener("scroll", handleViewportChange);
+ window.addEventListener("orientationchange", handleOrientationChange);
+ document.addEventListener("focusin", handleFocusIn, true);
+ document.addEventListener("focusout", handleFocusOut, true);
+
+ return {
+ setShellLocked,
+ cleanup: () => {
+ if (restoreTimer != null) {
+ clearTimeout(restoreTimer);
+ }
+ if (focusTimer != null) {
+ clearTimeout(focusTimer);
+ }
+ inputObserver?.disconnect();
+ inputObserver = null;
+ observedInput = null;
+ viewport.removeEventListener("resize", handleViewportChange);
+ viewport.removeEventListener("scroll", handleViewportChange);
+ window.removeEventListener("orientationchange", handleOrientationChange);
+ document.removeEventListener("focusin", handleFocusIn, true);
+ document.removeEventListener("focusout", handleFocusOut, true);
+ setShellLocked(false);
+ setKeyboardOpen(false);
+ setIosMobile(false);
+ root.style.removeProperty("--mobile-layout-height");
+ root.style.removeProperty("--mobile-viewport-height");
+ root.style.removeProperty("--mobile-chat-input-height");
+ },
+ };
+}
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index a07ed6376a6..c2b84e5fdda 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -28,6 +28,7 @@ import {
handleFirstUpdated,
handleUpdated,
} from "./app-lifecycle.ts";
+import { attachMobileViewportFixes } from "./app-mobile-viewport.ts";
import { renderApp } from "./app-render.ts";
import {
exportLogs as exportLogsInternal,
@@ -116,6 +117,10 @@ export class OpenClawApp extends LitElement {
clientInstanceId = generateUUID();
connectGeneration = 0;
@state() settings: UiSettings = loadSettings();
+ private shouldLockMobileShell() {
+ return this.connected && this.tab === "chat";
+ }
+
constructor() {
super();
if (isSupportedLocale(this.settings.locale)) {
@@ -449,6 +454,7 @@ export class OpenClawApp extends LitElement {
private popStateHandler = () =>
onPopStateInternal(this as unknown as Parameters[0]);
private topbarObserver: ResizeObserver | null = null;
+ private mobileViewportController: ReturnType | null = null;
private globalKeydownHandler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === "k") {
e.preventDefault();
@@ -480,6 +486,8 @@ export class OpenClawApp extends LitElement {
}
};
document.addEventListener("keydown", this.globalKeydownHandler);
+ this.mobileViewportController = attachMobileViewportFixes(this);
+ this.mobileViewportController.setShellLocked(this.shouldLockMobileShell());
handleConnected(this as unknown as Parameters[0]);
}
@@ -489,12 +497,15 @@ export class OpenClawApp extends LitElement {
disconnectedCallback() {
document.removeEventListener("keydown", this.globalKeydownHandler);
+ this.mobileViewportController?.cleanup();
+ this.mobileViewportController = null;
handleDisconnected(this as unknown as Parameters[0]);
super.disconnectedCallback();
}
protected updated(changed: Map) {
handleUpdated(this as unknown as Parameters[0], changed);
+ this.mobileViewportController?.setShellLocked(this.shouldLockMobileShell());
}
connect() {
diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts
index 3407288c03d..140163f5069 100644
--- a/ui/src/ui/navigation.browser.test.ts
+++ b/ui/src/ui/navigation.browser.test.ts
@@ -4,10 +4,23 @@ import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/
registerAppMountHooks();
+const originalVisualViewport = Object.getOwnPropertyDescriptor(window, "visualViewport");
+const originalUserAgent = Object.getOwnPropertyDescriptor(window.navigator, "userAgent");
+const originalPlatform = Object.getOwnPropertyDescriptor(window.navigator, "platform");
+const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(window.navigator, "maxTouchPoints");
+
function mountApp(pathname: string) {
return mountTestApp(pathname);
}
+function restoreProperty(target: object, key: string, descriptor?: PropertyDescriptor) {
+ if (descriptor) {
+ Object.defineProperty(target, key, descriptor);
+ return;
+ }
+ Reflect.deleteProperty(target, key);
+}
+
function nextFrame() {
return new Promise((resolve) => {
requestAnimationFrame(() => resolve());
@@ -87,6 +100,62 @@ describe("control UI routing", () => {
expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull();
});
+ it("uses 16px text controls on iOS mobile to avoid Safari auto-zoom", async () => {
+ document.documentElement.setAttribute("data-ios-mobile", "");
+ const field = document.createElement("label");
+ field.className = "field";
+ field.innerHTML = `
+
+
+
+ `;
+ document.body.append(field);
+ try {
+ const input = field.querySelector("input");
+ const textarea = field.querySelector("textarea");
+ const select = field.querySelector("select");
+ expect(input).not.toBeNull();
+ expect(textarea).not.toBeNull();
+ expect(select).not.toBeNull();
+ if (!input || !textarea || !select) {
+ return;
+ }
+
+ expect(getComputedStyle(input).fontSize).toBe("16px");
+ expect(getComputedStyle(textarea).fontSize).toBe("16px");
+ expect(getComputedStyle(select).fontSize).toBe("16px");
+ } finally {
+ field.remove();
+ document.documentElement.removeAttribute("data-ios-mobile");
+ }
+ });
+
+ it("keeps the mobile shell height override scoped to the iOS viewport fix", async () => {
+ const app = mountApp("/chat");
+ await app.updateComplete;
+
+ expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
+
+ const shell = app.querySelector(".shell");
+ expect(shell).not.toBeNull();
+ if (!shell) {
+ return;
+ }
+
+ document.documentElement.style.setProperty("--mobile-layout-height", "321px");
+ await nextFrame();
+ expect(getComputedStyle(shell).height).not.toBe("321px");
+
+ document.documentElement.setAttribute("data-ios-mobile", "");
+ app.setAttribute("data-ios-mobile", "");
+ await nextFrame();
+ expect(getComputedStyle(shell).height).toBe("321px");
+
+ app.removeAttribute("data-ios-mobile");
+ document.documentElement.removeAttribute("data-ios-mobile");
+ document.documentElement.style.removeProperty("--mobile-layout-height");
+ });
+
it("does not render a desktop sidebar resizer or inject a custom nav width", async () => {
const app = mountApp("/chat");
await app.updateComplete;
@@ -186,22 +255,25 @@ describe("control UI routing", () => {
}
});
- it("stacks the refreshed top navigation for narrow viewports", async () => {
+ it("keeps the refreshed top navigation in a single compact row on narrow viewports", async () => {
const app = mountApp("/chat");
await app.updateComplete;
expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
const shell = app.querySelector(".topnav-shell");
+ const actions = app.querySelector(".topnav-shell__actions");
const content = app.querySelector(".topnav-shell__content");
expect(shell).not.toBeNull();
+ expect(actions).not.toBeNull();
expect(content).not.toBeNull();
- if (!shell || !content) {
+ if (!shell || !actions || !content) {
return;
}
- expect(getComputedStyle(shell).flexWrap).toBe("wrap");
- expect(getComputedStyle(content).width).not.toBe("auto");
+ expect(getComputedStyle(shell).flexWrap).toBe("nowrap");
+ expect(getComputedStyle(actions).order).toBe("2");
+ expect(getComputedStyle(content).order).toBe("3");
});
it("keeps the mobile topbar nav toggle visible beside the search row", async () => {
@@ -228,6 +300,122 @@ describe("control UI routing", () => {
expect(actionsWidth).toBeLessThan(shellWidth);
});
+ it("lets the mobile search fill the remaining topbar space", async () => {
+ const app = mountApp("/chat");
+ await app.updateComplete;
+
+ expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
+
+ const actions = app.querySelector(".topnav-shell__actions");
+ const content = app.querySelector(".topnav-shell__content");
+ const search = app.querySelector(".topbar-search");
+ const searchShortcut = app.querySelector(".topbar-search__kbd");
+ expect(actions).not.toBeNull();
+ expect(content).not.toBeNull();
+ expect(search).not.toBeNull();
+ expect(searchShortcut).not.toBeNull();
+ if (!actions || !content || !search || !searchShortcut) {
+ return;
+ }
+
+ expect(getComputedStyle(actions).flexGrow).toBe("1");
+ expect(getComputedStyle(search).flexGrow).toBe("1");
+ expect(getComputedStyle(content).flexGrow).toBe("0");
+ expect(getComputedStyle(searchShortcut).display).toBe("none");
+ });
+
+ it("removes extra top padding from the mobile chat card", async () => {
+ const app = mountApp("/chat");
+ await app.updateComplete;
+
+ expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
+
+ const chat = app.querySelector(".chat");
+ expect(chat).not.toBeNull();
+ if (!chat) {
+ return;
+ }
+
+ expect(getComputedStyle(chat).paddingTop).toBe("0px");
+ });
+
+ it("only enables the iOS shell lock on the chat tab", async () => {
+ Object.defineProperty(window.navigator, "userAgent", {
+ configurable: true,
+ value: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)",
+ });
+ Object.defineProperty(window.navigator, "platform", {
+ configurable: true,
+ value: "iPhone",
+ });
+ Object.defineProperty(window.navigator, "maxTouchPoints", {
+ configurable: true,
+ value: 5,
+ });
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ value: Object.assign(new EventTarget(), { width: 400, height: 800 }),
+ });
+ try {
+ const chatApp = mountApp("/chat");
+ await chatApp.updateComplete;
+
+ expect(chatApp.hasAttribute("data-ios-shell-lock")).toBe(true);
+
+ chatApp.remove();
+ const sessionsApp = mountApp("/sessions");
+ await sessionsApp.updateComplete;
+
+ expect(sessionsApp.hasAttribute("data-ios-shell-lock")).toBe(false);
+ } finally {
+ restoreProperty(window, "visualViewport", originalVisualViewport);
+ restoreProperty(window.navigator, "userAgent", originalUserAgent);
+ restoreProperty(window.navigator, "platform", originalPlatform);
+ restoreProperty(window.navigator, "maxTouchPoints", originalMaxTouchPoints);
+ document.documentElement.removeAttribute("data-ios-mobile");
+ document.documentElement.removeAttribute("data-ios-keyboard-open");
+ document.documentElement.removeAttribute("data-ios-shell-lock");
+ document.body.removeAttribute("data-ios-mobile");
+ document.body.removeAttribute("data-ios-keyboard-open");
+ document.body.removeAttribute("data-ios-shell-lock");
+ }
+ });
+
+ it("keeps mobile chat controls on one row as header actions grow", async () => {
+ const app = mountApp("/chat");
+ await app.updateComplete;
+
+ expect(window.matchMedia("(max-width: 768px)").matches).toBe(true);
+
+ const sessionRow = app.querySelector(
+ ".content--chat .content-header .chat-controls__session-row",
+ );
+ const controls = app.querySelector(
+ ".content--chat .content-header .chat-controls",
+ );
+ const session = app.querySelector(
+ ".content--chat .content-header .chat-controls__session",
+ );
+ const model = app.querySelector(
+ ".content--chat .content-header .chat-controls__model",
+ );
+ expect(sessionRow).not.toBeNull();
+ expect(controls).not.toBeNull();
+ expect(session).not.toBeNull();
+ expect(model).not.toBeNull();
+ if (!sessionRow || !controls || !session || !model) {
+ return;
+ }
+
+ expect(getComputedStyle(sessionRow).display).toBe("flex");
+ expect(getComputedStyle(sessionRow).gap).toBe("4px");
+ expect(getComputedStyle(session).flexGrow).toBe("2");
+ expect(getComputedStyle(model).flexGrow).toBe("3");
+ expect(getComputedStyle(controls).display).toBe("flex");
+ expect(getComputedStyle(controls).flexWrap).toBe("nowrap");
+ expect(getComputedStyle(controls).gap).toBe("4px");
+ });
+
it("opens the mobile sidenav as a drawer from the topbar toggle", async () => {
const app = mountApp("/chat");
await app.updateComplete;