Merge b02d56f7dd9b131c1e6a348c79638920901d5011 into 9fb78453e088cd7b553d7779faa0de5c83708e70
This commit is contained in:
commit
1f3df9040e
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
270
ui/src/ui/app-mobile-viewport.test.ts
Normal file
270
ui/src/ui/app-mobile-viewport.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
221
ui/src/ui/app-mobile-viewport.ts
Normal file
221
ui/src/ui/app-mobile-viewport.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user