diff --git a/.changeset/mobile-safe-area.md b/.changeset/mobile-safe-area.md new file mode 100644 index 0000000000..050eb67880 --- /dev/null +++ b/.changeset/mobile-safe-area.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix mobile safe-area handling, including the composer floating above the on-screen keyboard on iOS, doubled landscape insets, the PWA top bar under the notch, and toasts overlapping the composer as it grows. diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html index dff0375e40..27a99e5d4d 100644 --- a/apps/kimi-web/index.html +++ b/apps/kimi-web/index.html @@ -3,7 +3,7 @@
- + diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index d71852bc8b..e85b5da01e 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -143,6 +143,28 @@ function openOnboarding(): void { showOnboarding.value = true; } +// iOS Safari does not shrink `dvh` for the on-screen keyboard. Instead it pans +// the visual viewport (offsetTop > 0) to reveal the focused field, which a +// 100dvh in-flow shell cannot follow: the dock ends up behind the keyboard, or +// the page shows a blank band past the shell's bottom edge. Pin the shell to +// the VISUAL viewport instead: position:fixed + top/height mirrored from +// visualViewport (height shrinks with the keyboard, offsetTop tracks the pan). +// No-ops on desktop, where offsetTop is 0 and height equals innerHeight. +let appHeightRaf = 0; +function setAppHeight(): void { + const vv = window.visualViewport; + const root = document.documentElement.style; + root.setProperty('--app-height', `${vv?.height ?? window.innerHeight}px`); + root.setProperty('--app-top', `${vv?.offsetTop ?? 0}px`); +} +function syncAppHeight(): void { + if (appHeightRaf) return; + appHeightRaf = requestAnimationFrame(() => { + appHeightRaf = 0; + setAppHeight(); + }); +} + onMounted(() => { // Register the 401 listener before the first requests go out, so a token // rejection during the initial load() can never be missed. @@ -154,6 +176,10 @@ onMounted(() => { }); void client.load(); loadSidebarCollapsed(); + setAppHeight(); + window.visualViewport?.addEventListener('resize', syncAppHeight); + window.visualViewport?.addEventListener('scroll', syncAppHeight); + window.addEventListener('resize', syncAppHeight); // Capture-phase so Escape closes the side detail layer BEFORE the // conversation pane's bubble-phase handler interrupts a running prompt. document.addEventListener('keydown', onGlobalKeydown, true); @@ -161,6 +187,15 @@ onMounted(() => { onUnmounted(() => { document.removeEventListener('keydown', onGlobalKeydown, true); + window.visualViewport?.removeEventListener('resize', syncAppHeight); + window.visualViewport?.removeEventListener('scroll', syncAppHeight); + window.removeEventListener('resize', syncAppHeight); + if (appHeightRaf) { + cancelAnimationFrame(appHeightRaf); + appHeightRaf = 0; + } + document.documentElement.style.removeProperty('--app-height'); + document.documentElement.style.removeProperty('--app-top'); if (offAuthRequired !== null) { offAuthRequired(); offAuthRequired = null; @@ -1070,8 +1105,17 @@ function openPr(url: string): void { .gload-fade-leave-to { opacity: 0; } .app-shell { + /* Pinned to the visual viewport (see setAppHeight): --app-top tracks iOS's + keyboard pan and --app-height shrinks with the keyboard, so the shell + always covers exactly the visible area. Fixed positioning keeps it out of + the document flow that iOS pans. */ + position: fixed; + top: var(--app-top, 0px); + left: 0; + right: 0; height: 100vh; height: 100dvh; + height: var(--app-height, 100dvh); display: flex; flex-direction: column; overflow: hidden; @@ -1247,10 +1291,10 @@ function openPr(url: string): void { .auth-page { align-items: flex-start; padding: - max(48px, env(safe-area-inset-top)) - max(20px, env(safe-area-inset-right)) - max(24px, env(safe-area-inset-bottom)) - max(20px, env(safe-area-inset-left)); + max(48px, var(--safe-top)) + max(20px, var(--safe-right)) + max(24px, var(--safe-bottom)) + max(20px, var(--safe-left)); } .auth-page-copy h1 { font-size: 26px; diff --git a/apps/kimi-web/src/components/WarningToasts.vue b/apps/kimi-web/src/components/WarningToasts.vue index a6addba383..ba03f448d4 100644 --- a/apps/kimi-web/src/components/WarningToasts.vue +++ b/apps/kimi-web/src/components/WarningToasts.vue @@ -295,7 +295,9 @@ onUnmounted(() => { .toasts { left: 12px; right: 12px; - bottom: calc(76px + env(safe-area-inset-bottom)); + /* Sit just above the chat dock; --dock-h already includes the dock's own + safe-area padding, so no extra safe-bottom term is needed. */ + bottom: calc(var(--dock-h, 76px) + 8px); width: auto; max-height: 50vh; } diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue index ab84330a3d..25bdb68d48 100644 --- a/apps/kimi-web/src/components/chat/ChatDock.vue +++ b/apps/kimi-web/src/components/chat/ChatDock.vue @@ -3,7 +3,7 @@ -