Merge b02d56f7dd9b131c1e6a348c79638920901d5011 into 9fb78453e088cd7b553d7779faa0de5c83708e70

This commit is contained in:
RuiqianZhang 2026-03-20 22:00:31 -07:00 committed by GitHub
commit 1f3df9040e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 868 additions and 114 deletions

View File

@ -8,59 +8,6 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<script>
(function () {
var THEMES = { claw: 1, knot: 1, dash: 1 };
var MODES = { system: 1, light: 1, dark: 1 };
var LEGACY = {
dark: "claw:dark",
light: "claw:light",
openknot: "knot:dark",
fieldmanual: "dash:dark",
clawdash: "dash:light",
system: "claw:system",
};
try {
var keys = Object.keys(localStorage);
var raw;
for (var i = 0; i < keys.length; i++) {
if (keys[i].indexOf("openclaw.control.settings.v1") === 0) {
raw = localStorage.getItem(keys[i]);
if (raw) break;
}
}
if (!raw) return;
var s = JSON.parse(raw);
var t = s && s.theme;
var m = s && s.themeMode;
if (typeof t !== "string") t = "";
if (typeof m !== "string") m = "";
var legacy = LEGACY[t];
var theme = THEMES[t] ? t : legacy ? legacy.split(":")[0] : "claw";
var mode = MODES[m] ? m : legacy ? legacy.split(":")[1] : "system";
if (mode === "system") {
mode = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
var resolved =
theme === "knot"
? mode === "light"
? "openknot-light"
: "openknot"
: theme === "dash"
? mode === "light"
? "dash-light"
: "dash"
: mode === "light"
? "light"
: "dark";
document.documentElement.setAttribute("data-theme", resolved);
document.documentElement.setAttribute(
"data-theme-mode",
resolved.indexOf("light") !== -1 ? "light" : "dark",
);
} catch (e) {}
})();
</script>
</head>
<body>
<openclaw-app></openclaw-app>

View File

@ -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;

View File

@ -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<unknown>;
};
host.updateComplete = Promise.resolve();
host.innerHTML = `
<div class="content--chat">
<div class="agent-chat__input">
<textarea></textarea>
</div>
</div>
`;
const input = host.querySelector<HTMLElement>(".agent-chat__input");
const textarea = host.querySelector<HTMLTextAreaElement>("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<DOMRect>,
});
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();
});
});

View File

@ -0,0 +1,221 @@
type MobileViewportHost = {
updateComplete: Promise<unknown>;
};
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<HTMLElement>(".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");
},
};
}

View File

@ -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<typeof onPopStateInternal>[0]);
private topbarObserver: ResizeObserver | null = null;
private mobileViewportController: ReturnType<typeof attachMobileViewportFixes> | 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<typeof handleConnected>[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<typeof handleDisconnected>[0]);
super.disconnectedCallback();
}
protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
this.mobileViewportController?.setShellLocked(this.shouldLockMobileShell());
}
connect() {

View File

@ -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<void>((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 = `
<input type="text" value="alpha" />
<textarea>beta</textarea>
<select><option>gamma</option></select>
`;
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<HTMLElement>(".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<HTMLElement>(".topnav-shell");
const actions = app.querySelector<HTMLElement>(".topnav-shell__actions");
const content = app.querySelector<HTMLElement>(".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<HTMLElement>(".topnav-shell__actions");
const content = app.querySelector<HTMLElement>(".topnav-shell__content");
const search = app.querySelector<HTMLElement>(".topbar-search");
const searchShortcut = app.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(
".content--chat .content-header .chat-controls__session-row",
);
const controls = app.querySelector<HTMLElement>(
".content--chat .content-header .chat-controls",
);
const session = app.querySelector<HTMLElement>(
".content--chat .content-header .chat-controls__session",
);
const model = app.querySelector<HTMLElement>(
".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;