From e08a640ea092d7ade7e3ca6e92eef6cbea3dfb72 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sat, 14 Mar 2026 17:00:53 +0800 Subject: [PATCH 01/11] fix(ui): Fix mobile layout and styling issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add viewport settings to disable user zoom for a better mobile experience - Set fixed height and hide overflow for html, body, and the app container - Use svh units to ensure the shell component displays correctly on mobile devices - Change the chat header layout from flexbox to grid to improve responsive design - Adjust the chat controls area’s grid layout and alignment - Optimize the selector component’s font size and padding for small screens - Hide the divider and adjust the input box’s bottom margin to fit the safe area - Set min-height: 0 to prevent content overflow issues --- ui/index.html | 81 +++-------- ui/src/styles/layout.mobile.css | 231 ++++++++++++++++++++++++-------- 2 files changed, 189 insertions(+), 123 deletions(-) diff --git a/ui/index.html b/ui/index.html index a36c6850158..2ac9415f49a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,69 +1,18 @@ - - - - OpenClaw Control - - - - - - - - - - + + + + + OpenClaw Control + + + + + + + + + + diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 6d943253804..46e6a24ae56 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 { + 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); + } + + .shell, + .shell--chat, + .shell--nav-collapsed, + .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; From e92164996aaf1354d06288424111e24ceb2c4e3b Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sat, 14 Mar 2026 17:33:07 +0800 Subject: [PATCH 02/11] fix(ui): restore mobile zoom and prevent iOS auto-zoom --- ui/index.html | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/ui/index.html b/ui/index.html index 2ac9415f49a..dc03f49115c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,18 +1,16 @@ - - - - - OpenClaw Control - - - - - - - - - - + + + + OpenClaw Control + + + + + + + + + From 58066c0e63993267b605062d7bcde7fa080c1f77 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 09:07:22 +0800 Subject: [PATCH 03/11] fix(ui): stabilize iOS mobile viewport behavior --- ui/src/ui/app-mobile-viewport.ts | 173 +++++++++++++++++++++++++++++++ ui/src/ui/app.ts | 7 ++ 2 files changed, 180 insertions(+) create mode 100644 ui/src/ui/app-mobile-viewport.ts diff --git a/ui/src/ui/app-mobile-viewport.ts b/ui/src/ui/app-mobile-viewport.ts new file mode 100644 index 00000000000..f275a2b6854 --- /dev/null +++ b/ui/src/ui/app-mobile-viewport.ts @@ -0,0 +1,173 @@ +type MobileViewportHost = { + updateComplete: Promise; +}; + +type MobileViewportController = { + cleanup: () => void; + setShellLocked: (locked: boolean) => void; +}; + +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 target.isContentEditable; +} + +export function attachMobileViewportFixes( + host: MobileViewportHost & HTMLElement, +): MobileViewportController { + if (typeof window === "undefined" || typeof document === "undefined") { + return { cleanup: () => {}, setShellLocked: () => {} }; + } + const viewport = window.visualViewport; + if (!viewport || !isIosDevice()) { + return { cleanup: () => {}, setShellLocked: () => {} }; + } + + const root = document.documentElement; + const body = document.body; + let restoreTimer: number | null = null; + let focusTimer: number | null = null; + let inputObserver: ResizeObserver | null = null; + let observedInput: HTMLElement | null = null; + let maxViewportHeight = Math.round(viewport.height); + + const setShellLocked = (locked: boolean) => { + 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 = () => { + const height = Math.round(viewport.height); + maxViewportHeight = Math.max(maxViewportHeight, height); + root.style.setProperty("--mobile-viewport-height", `${height}px`); + root.style.setProperty("--mobile-layout-height", `${maxViewportHeight}px`); + setKeyboardOpen(maxViewportHeight - height > 120); + }; + + 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 (restoreTimer != null) { + clearTimeout(restoreTimer); + } + restoreTimer = window.setTimeout(() => { + restoreTimer = null; + 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 handleFocusIn = (event: FocusEvent) => { + if (!isTextEntryElement(event.target)) { + return; + } + 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", handleViewportChange); + 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", handleViewportChange); + document.removeEventListener("focusin", handleFocusIn, true); + document.removeEventListener("focusout", handleFocusOut, true); + setShellLocked(false); + setKeyboardOpen(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..629ab307da5 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, @@ -449,6 +450,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 +482,8 @@ export class OpenClawApp extends LitElement { } }; document.addEventListener("keydown", this.globalKeydownHandler); + this.mobileViewportController = attachMobileViewportFixes(this); + this.mobileViewportController.setShellLocked(this.connected); handleConnected(this as unknown as Parameters[0]); } @@ -489,12 +493,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.connected); } connect() { From 99bb8bad1b3cf713d426b42d2adea2f4215942f5 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 09:17:52 +0800 Subject: [PATCH 04/11] fix(ui): tighten mobile topbar spacing --- ui/src/ui/navigation.browser.test.ts | 29 ++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 3407288c03d..b1d5910e53a 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -186,22 +186,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 +231,24 @@ describe("control UI routing", () => { expect(actionsWidth).toBeLessThan(shellWidth); }); + it("caps the mobile topbar search width before it hides the current tab", async () => { + const app = mountApp("/chat"); + await app.updateComplete; + + expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); + + const search = app.querySelector(".topbar-search"); + const searchShortcut = app.querySelector(".topbar-search__kbd"); + expect(search).not.toBeNull(); + expect(searchShortcut).not.toBeNull(); + if (!search || !searchShortcut) { + return; + } + + expect(getComputedStyle(search).maxWidth).toBe("108px"); + expect(getComputedStyle(searchShortcut).display).toBe("none"); + }); + it("opens the mobile sidenav as a drawer from the topbar toggle", async () => { const app = mountApp("/chat"); await app.updateComplete; From 1e90a0cc1e75d53deb138a76b8167ba337a1399c Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 09:39:02 +0800 Subject: [PATCH 05/11] fix(ui): rebalance mobile topbar layout --- ui/src/ui/navigation.browser.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index b1d5910e53a..9d8fedb316b 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -231,21 +231,27 @@ describe("control UI routing", () => { expect(actionsWidth).toBeLessThan(shellWidth); }); - it("caps the mobile topbar search width before it hides the current tab", async () => { + 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 (!search || !searchShortcut) { + if (!actions || !content || !search || !searchShortcut) { return; } - expect(getComputedStyle(search).maxWidth).toBe("108px"); + expect(getComputedStyle(actions).flexGrow).toBe("1"); + expect(getComputedStyle(search).flexGrow).toBe("1"); + expect(getComputedStyle(content).flexGrow).toBe("0"); expect(getComputedStyle(searchShortcut).display).toBe("none"); }); From 719a45ec6f99e6d35c60bd14aa1be8d5297e5b70 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 10:07:16 +0800 Subject: [PATCH 06/11] fix(ui): finish mobile Safari layout fixes --- ui/src/styles/chat/layout.css | 4 +++ ui/src/ui/app-mobile-viewport.ts | 22 ++++++++++++-- ui/src/ui/navigation.browser.test.ts | 45 ++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 8c68a2327b4..658170a0ec1 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -18,6 +18,10 @@ box-shadow: none !important; } +.card.chat { + padding-top: 0; +} + /* Chat header - fixed at top, transparent */ .chat-header { display: flex; diff --git a/ui/src/ui/app-mobile-viewport.ts b/ui/src/ui/app-mobile-viewport.ts index f275a2b6854..6ce5a288798 100644 --- a/ui/src/ui/app-mobile-viewport.ts +++ b/ui/src/ui/app-mobile-viewport.ts @@ -31,13 +31,30 @@ export function attachMobileViewportFixes( if (typeof window === "undefined" || typeof document === "undefined") { return { cleanup: () => {}, setShellLocked: () => {} }; } - const viewport = window.visualViewport; - if (!viewport || !isIosDevice()) { + + 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); + }; + setIosMobile(true); + + const viewport = window.visualViewport; + if (!viewport) { + return { + cleanup: () => { + setIosMobile(false); + }, + setShellLocked: () => {}, + }; + } + let restoreTimer: number | null = null; let focusTimer: number | null = null; let inputObserver: ResizeObserver | null = null; @@ -165,6 +182,7 @@ export function attachMobileViewportFixes( 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/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 9d8fedb316b..8decebf7748 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -87,6 +87,36 @@ 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("does not render a desktop sidebar resizer or inject a custom nav width", async () => { const app = mountApp("/chat"); await app.updateComplete; @@ -255,6 +285,21 @@ describe("control UI routing", () => { 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("opens the mobile sidenav as a drawer from the topbar toggle", async () => { const app = mountApp("/chat"); await app.updateComplete; From a32ffcab5124009709f8b8910e07c38ed671ed53 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 10:28:32 +0800 Subject: [PATCH 07/11] fix(ui): keep mobile chat controls on one row --- ui/src/ui/navigation.browser.test.ts | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 8decebf7748..875e2a5c55d 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -300,6 +300,41 @@ describe("control UI routing", () => { expect(getComputedStyle(chat).paddingTop).toBe("0px"); }); + 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; From a7177aca141514b17c7bddade1b38766ba4a71fb Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 11:00:43 +0800 Subject: [PATCH 08/11] fix(ui): harden iOS mobile viewport handling --- ui/src/styles/layout.mobile.css | 10 +- ui/src/ui/app-mobile-viewport.test.ts | 199 ++++++++++++++++++++++++++ ui/src/ui/app-mobile-viewport.ts | 36 +++-- ui/src/ui/navigation.browser.test.ts | 26 ++++ 4 files changed, 257 insertions(+), 14 deletions(-) create mode 100644 ui/src/ui/app-mobile-viewport.test.ts diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 46e6a24ae56..1984813f41f 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -244,7 +244,7 @@ overflow: hidden; } - openclaw-app { + 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)); } @@ -267,10 +267,10 @@ scroll-padding-bottom: calc(var(--mobile-chat-input-height, 112px) + 16px); } - .shell, - .shell--chat, - .shell--nav-collapsed, - .shell--chat-focus { + 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)); } 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..744345fe106 --- /dev/null +++ b/ui/src/ui/app-mobile-viewport.test.ts @@ -0,0 +1,199 @@ +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("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("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(); + }); +}); diff --git a/ui/src/ui/app-mobile-viewport.ts b/ui/src/ui/app-mobile-viewport.ts index 6ce5a288798..9dc932c649e 100644 --- a/ui/src/ui/app-mobile-viewport.ts +++ b/ui/src/ui/app-mobile-viewport.ts @@ -7,6 +7,10 @@ type MobileViewportController = { setShellLocked: (locked: boolean) => void; }; +type ViewportUpdateOptions = { + resetBaseline?: boolean; +}; + function isIosDevice(): boolean { const { userAgent, platform, maxTouchPoints } = navigator; return /iPad|iPhone|iPod/.test(userAgent) || (platform === "MacIntel" && maxTouchPoints > 1); @@ -22,7 +26,7 @@ function isTextEntryElement(target: EventTarget | null): boolean { if (target instanceof HTMLInputElement) { return target.type !== "checkbox" && target.type !== "radio" && target.type !== "button"; } - return target.isContentEditable; + return Boolean(target.isContentEditable); } export function attachMobileViewportFixes( @@ -59,7 +63,7 @@ export function attachMobileViewportFixes( let focusTimer: number | null = null; let inputObserver: ResizeObserver | null = null; let observedInput: HTMLElement | null = null; - let maxViewportHeight = Math.round(viewport.height); + let baselineViewportHeight = Math.round(viewport.height); const setShellLocked = (locked: boolean) => { root.toggleAttribute("data-ios-shell-lock", locked); @@ -73,16 +77,25 @@ export function attachMobileViewportFixes( host.toggleAttribute("data-ios-keyboard-open", open); }; - const updateViewportHeight = () => { + const updateViewportHeight = ({ resetBaseline = false }: ViewportUpdateOptions = {}) => { const height = Math.round(viewport.height); - maxViewportHeight = Math.max(maxViewportHeight, height); + const textEntryFocused = isTextEntryElement(document.activeElement); + if (resetBaseline) { + 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 > 120; + if (!keyboardOpen) { + baselineViewportHeight = height; + } root.style.setProperty("--mobile-viewport-height", `${height}px`); - root.style.setProperty("--mobile-layout-height", `${maxViewportHeight}px`); - setKeyboardOpen(maxViewportHeight - height > 120); + root.style.setProperty("--mobile-layout-height", `${baselineViewportHeight}px`); + setKeyboardOpen(keyboardOpen); }; const syncChatInputMetrics = () => { - const input = host.querySelector(".content--chat .agent-chat__input"); + const input = host.querySelector(".content--chat .agent-chat__input"); if (!input) { inputObserver?.disconnect(); inputObserver = null; @@ -136,6 +149,11 @@ export function attachMobileViewportFixes( restoreDocumentScroll(); }; + const handleOrientationChange = () => { + updateViewportHeight({ resetBaseline: true }); + restoreDocumentScroll(); + }; + const handleFocusIn = (event: FocusEvent) => { if (!isTextEntryElement(event.target)) { return; @@ -159,7 +177,7 @@ export function attachMobileViewportFixes( void host.updateComplete.then(() => syncChatInputMetrics()); viewport.addEventListener("resize", handleViewportChange); viewport.addEventListener("scroll", handleViewportChange); - window.addEventListener("orientationchange", handleViewportChange); + window.addEventListener("orientationchange", handleOrientationChange); document.addEventListener("focusin", handleFocusIn, true); document.addEventListener("focusout", handleFocusOut, true); @@ -177,7 +195,7 @@ export function attachMobileViewportFixes( observedInput = null; viewport.removeEventListener("resize", handleViewportChange); viewport.removeEventListener("scroll", handleViewportChange); - window.removeEventListener("orientationchange", handleViewportChange); + window.removeEventListener("orientationchange", handleOrientationChange); document.removeEventListener("focusin", handleFocusIn, true); document.removeEventListener("focusout", handleFocusOut, true); setShellLocked(false); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 875e2a5c55d..023e52eb7ed 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -117,6 +117,32 @@ describe("control UI routing", () => { } }); + 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; From f87588d49039a51892837e3c71905b6395564d07 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 11:11:48 +0800 Subject: [PATCH 09/11] fix(ui): harden iOS viewport follow-ups --- ui/src/ui/app-mobile-viewport.test.ts | 58 +++++++++++++++++++++++++++ ui/src/ui/app-mobile-viewport.ts | 24 ++++++++--- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/ui/src/ui/app-mobile-viewport.test.ts b/ui/src/ui/app-mobile-viewport.test.ts index 744345fe106..28583fd8983 100644 --- a/ui/src/ui/app-mobile-viewport.test.ts +++ b/ui/src/ui/app-mobile-viewport.test.ts @@ -172,6 +172,31 @@ describe("attachMobileViewportFixes", () => { 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); @@ -196,4 +221,37 @@ describe("attachMobileViewportFixes", () => { 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 index 9dc932c649e..15b17a3f510 100644 --- a/ui/src/ui/app-mobile-viewport.ts +++ b/ui/src/ui/app-mobile-viewport.ts @@ -11,6 +11,8 @@ 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); @@ -64,8 +66,10 @@ export function attachMobileViewportFixes( 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); @@ -82,15 +86,18 @@ export function attachMobileViewportFixes( 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 > 120; - if (!keyboardOpen) { - baselineViewportHeight = height; - } + 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", `${baselineViewportHeight}px`); + root.style.setProperty("--mobile-layout-height", `${layoutHeight}px`); setKeyboardOpen(keyboardOpen); }; @@ -122,11 +129,17 @@ export function attachMobileViewportFixes( }; const restoreDocumentScroll = () => { + if (!shellLocked) { + return; + } if (restoreTimer != null) { clearTimeout(restoreTimer); } restoreTimer = window.setTimeout(() => { restoreTimer = null; + if (!shellLocked) { + return; + } if (isTextEntryElement(document.activeElement)) { return; } @@ -158,6 +171,7 @@ export function attachMobileViewportFixes( if (!isTextEntryElement(event.target)) { return; } + updateViewportHeight({ resetBaseline: true }); if (focusTimer != null) { clearTimeout(focusTimer); } From 54e212acaaeb493ef96d607fd2e8e5a9f2915d29 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 11:24:03 +0800 Subject: [PATCH 10/11] fix(ui): scope mobile chat viewport overrides --- ui/src/styles/chat/layout.css | 4 -- ui/src/ui/app.ts | 8 +++- ui/src/ui/navigation.browser.test.ts | 55 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 658170a0ec1..8c68a2327b4 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -18,10 +18,6 @@ box-shadow: none !important; } -.card.chat { - padding-top: 0; -} - /* Chat header - fixed at top, transparent */ .chat-header { display: flex; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 629ab307da5..c2b84e5fdda 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -117,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)) { @@ -483,7 +487,7 @@ export class OpenClawApp extends LitElement { }; document.addEventListener("keydown", this.globalKeydownHandler); this.mobileViewportController = attachMobileViewportFixes(this); - this.mobileViewportController.setShellLocked(this.connected); + this.mobileViewportController.setShellLocked(this.shouldLockMobileShell()); handleConnected(this as unknown as Parameters[0]); } @@ -501,7 +505,7 @@ export class OpenClawApp extends LitElement { protected updated(changed: Map) { handleUpdated(this as unknown as Parameters[0], changed); - this.mobileViewportController?.setShellLocked(this.connected); + 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 023e52eb7ed..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()); @@ -326,6 +339,48 @@ describe("control UI routing", () => { 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; From b02d56f7dd9b131c1e6a348c79638920901d5011 Mon Sep 17 00:00:00 2001 From: ruiqian Date: Sun, 15 Mar 2026 11:35:13 +0800 Subject: [PATCH 11/11] fix(ui): gate iOS viewport fix on visualViewport --- ui/src/ui/app-mobile-viewport.test.ts | 13 +++++++++++++ ui/src/ui/app-mobile-viewport.ts | 6 ++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/ui/src/ui/app-mobile-viewport.test.ts b/ui/src/ui/app-mobile-viewport.test.ts index 28583fd8983..a5e09cf1eaf 100644 --- a/ui/src/ui/app-mobile-viewport.test.ts +++ b/ui/src/ui/app-mobile-viewport.test.ts @@ -155,6 +155,19 @@ describe("attachMobileViewportFixes", () => { 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); diff --git a/ui/src/ui/app-mobile-viewport.ts b/ui/src/ui/app-mobile-viewport.ts index 15b17a3f510..3f197b8dd76 100644 --- a/ui/src/ui/app-mobile-viewport.ts +++ b/ui/src/ui/app-mobile-viewport.ts @@ -49,17 +49,15 @@ export function attachMobileViewportFixes( body.toggleAttribute("data-ios-mobile", active); host.toggleAttribute("data-ios-mobile", active); }; - setIosMobile(true); const viewport = window.visualViewport; if (!viewport) { return { - cleanup: () => { - setIosMobile(false); - }, + cleanup: () => {}, setShellLocked: () => {}, }; } + setIosMobile(true); let restoreTimer: number | null = null; let focusTimer: number | null = null;