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;