Skip to content

Commit 6eb8e13

Browse files
authored
fix(kimi-web): improve mobile safe-area handling (#1459)
* fix(kimi-web): improve mobile safe-area handling * fix(kimi-web): restore dock-height fallback where ChatDock is absent * fix(kimi-web): pin the app shell to the visual viewport height * fix(kimi-web): pin the app shell to the visual viewport * chore: add changeset for mobile safe-area fixes
1 parent 5d6ff02 commit 6eb8e13

12 files changed

Lines changed: 124 additions & 24 deletions

File tree

.changeset/mobile-safe-area.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
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.

apps/kimi-web/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8" />
55
<link rel="icon" href="/favicon.ico" sizes="64x64" />
6-
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
77
<meta name="color-scheme" content="light dark" />
88
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
99
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" />

apps/kimi-web/src/App.vue

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,28 @@ function openOnboarding(): void {
143143
showOnboarding.value = true;
144144
}
145145
146+
// iOS Safari does not shrink `dvh` for the on-screen keyboard. Instead it pans
147+
// the visual viewport (offsetTop > 0) to reveal the focused field, which a
148+
// 100dvh in-flow shell cannot follow: the dock ends up behind the keyboard, or
149+
// the page shows a blank band past the shell's bottom edge. Pin the shell to
150+
// the VISUAL viewport instead: position:fixed + top/height mirrored from
151+
// visualViewport (height shrinks with the keyboard, offsetTop tracks the pan).
152+
// No-ops on desktop, where offsetTop is 0 and height equals innerHeight.
153+
let appHeightRaf = 0;
154+
function setAppHeight(): void {
155+
const vv = window.visualViewport;
156+
const root = document.documentElement.style;
157+
root.setProperty('--app-height', `${vv?.height ?? window.innerHeight}px`);
158+
root.setProperty('--app-top', `${vv?.offsetTop ?? 0}px`);
159+
}
160+
function syncAppHeight(): void {
161+
if (appHeightRaf) return;
162+
appHeightRaf = requestAnimationFrame(() => {
163+
appHeightRaf = 0;
164+
setAppHeight();
165+
});
166+
}
167+
146168
onMounted(() => {
147169
// Register the 401 listener before the first requests go out, so a token
148170
// rejection during the initial load() can never be missed.
@@ -154,13 +176,26 @@ onMounted(() => {
154176
});
155177
void client.load();
156178
loadSidebarCollapsed();
179+
setAppHeight();
180+
window.visualViewport?.addEventListener('resize', syncAppHeight);
181+
window.visualViewport?.addEventListener('scroll', syncAppHeight);
182+
window.addEventListener('resize', syncAppHeight);
157183
// Capture-phase so Escape closes the side detail layer BEFORE the
158184
// conversation pane's bubble-phase handler interrupts a running prompt.
159185
document.addEventListener('keydown', onGlobalKeydown, true);
160186
});
161187
162188
onUnmounted(() => {
163189
document.removeEventListener('keydown', onGlobalKeydown, true);
190+
window.visualViewport?.removeEventListener('resize', syncAppHeight);
191+
window.visualViewport?.removeEventListener('scroll', syncAppHeight);
192+
window.removeEventListener('resize', syncAppHeight);
193+
if (appHeightRaf) {
194+
cancelAnimationFrame(appHeightRaf);
195+
appHeightRaf = 0;
196+
}
197+
document.documentElement.style.removeProperty('--app-height');
198+
document.documentElement.style.removeProperty('--app-top');
164199
if (offAuthRequired !== null) {
165200
offAuthRequired();
166201
offAuthRequired = null;
@@ -1070,8 +1105,17 @@ function openPr(url: string): void {
10701105
.gload-fade-leave-to { opacity: 0; }
10711106
10721107
.app-shell {
1108+
/* Pinned to the visual viewport (see setAppHeight): --app-top tracks iOS's
1109+
keyboard pan and --app-height shrinks with the keyboard, so the shell
1110+
always covers exactly the visible area. Fixed positioning keeps it out of
1111+
the document flow that iOS pans. */
1112+
position: fixed;
1113+
top: var(--app-top, 0px);
1114+
left: 0;
1115+
right: 0;
10731116
height: 100vh;
10741117
height: 100dvh;
1118+
height: var(--app-height, 100dvh);
10751119
display: flex;
10761120
flex-direction: column;
10771121
overflow: hidden;
@@ -1247,10 +1291,10 @@ function openPr(url: string): void {
12471291
.auth-page {
12481292
align-items: flex-start;
12491293
padding:
1250-
max(48px, env(safe-area-inset-top))
1251-
max(20px, env(safe-area-inset-right))
1252-
max(24px, env(safe-area-inset-bottom))
1253-
max(20px, env(safe-area-inset-left));
1294+
max(48px, var(--safe-top))
1295+
max(20px, var(--safe-right))
1296+
max(24px, var(--safe-bottom))
1297+
max(20px, var(--safe-left));
12541298
}
12551299
.auth-page-copy h1 {
12561300
font-size: 26px;

apps/kimi-web/src/components/WarningToasts.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,9 @@ onUnmounted(() => {
295295
.toasts {
296296
left: 12px;
297297
right: 12px;
298-
bottom: calc(76px + env(safe-area-inset-bottom));
298+
/* Sit just above the chat dock; --dock-h already includes the dock's own
299+
safe-area padding, so no extra safe-bottom term is needed. */
300+
bottom: calc(var(--dock-h, 76px) + 8px);
299301
width: auto;
300302
max-height: 50vh;
301303
}

apps/kimi-web/src/components/chat/ChatDock.vue

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<!-- pending question/approval cards, and the composer. Only rendered inside a -->
44
<!-- chat-pane group so it never leaks into files/tasks/preview/btw panes. -->
55
<script setup lang="ts">
6-
import { onUnmounted, ref, watch } from 'vue';
6+
import { onMounted, onUnmounted, ref, watch } from 'vue';
77
import { useI18n } from 'vue-i18n';
88
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoView, UIQuestion } from '../../types';
99
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
@@ -91,6 +91,7 @@ const composerRef = ref<{
9191
} | null>(null);
9292
const workPanelRef = ref<HTMLElement | null>(null);
9393
const workbarRef = ref<HTMLElement | null>(null);
94+
const dockRef = ref<HTMLElement | null>(null);
9495
9596
function loadForEdit(value: string): boolean {
9697
// The nested Composer is only rendered in ChatDock's v-else — when a pending
@@ -128,17 +129,36 @@ watch(
128129
{ immediate: true },
129130
);
130131
132+
let dockResizeObserver: ResizeObserver | null = null;
133+
134+
function publishDockHeight(): void {
135+
// Border-box height of the dock, exposed so fixed overlays (e.g. toasts) can
136+
// anchor just above the composer. offsetHeight includes the dock's own
137+
// safe-area padding, so consumers don't need to add safe-bottom again.
138+
const height = dockRef.value?.offsetHeight ?? 0;
139+
document.documentElement.style.setProperty('--dock-h', `${height}px`);
140+
}
141+
142+
onMounted(() => {
143+
if (typeof ResizeObserver !== 'function' || !dockRef.value) return;
144+
dockResizeObserver = new ResizeObserver(publishDockHeight);
145+
dockResizeObserver.observe(dockRef.value);
146+
publishDockHeight();
147+
});
148+
131149
onUnmounted(() => {
132150
if (typeof document !== 'undefined') {
133151
document.removeEventListener('mousedown', onDocumentMouseDown, true);
134152
}
153+
dockResizeObserver?.disconnect();
154+
dockResizeObserver = null;
135155
});
136156
137157
defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
138158
</script>
139159

140160
<template>
141-
<div class="chat-dock" :class="[mobile ? 'align-mobile' : 'align-center']" @click.stop>
161+
<div ref="dockRef" class="chat-dock" :class="[mobile ? 'align-mobile' : 'align-center']" @click.stop>
142162
<Transition name="dock-panel">
143163
<div
144164
ref="workPanelRef"
@@ -363,12 +383,10 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
363383
364384
@media (max-width: 640px) {
365385
.chat-dock {
366-
--dock-inline-left: max(12px, env(safe-area-inset-left));
367-
--dock-inline-right: max(12px, env(safe-area-inset-right));
368-
}
369-
.chat-dock.align-mobile {
370-
padding-left: env(safe-area-inset-left);
371-
padding-right: env(safe-area-inset-right);
386+
/* Inline (landscape) safe-area lives here only; the inner composer /
387+
workbar read --dock-inline-* so the inset is applied exactly once. */
388+
--dock-inline-left: max(12px, var(--safe-left));
389+
--dock-inline-right: max(12px, var(--safe-right));
372390
}
373391
.dock-work-panel {
374392
left: 10px;

apps/kimi-web/src/components/chat/ChatPane.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1133,7 +1133,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
11331133
.chat {
11341134
box-sizing: border-box;
11351135
width: 100%;
1136-
padding: 14px max(12px, env(safe-area-inset-right)) 18px max(12px, env(safe-area-inset-left));
1136+
padding: 14px max(12px, var(--safe-right)) 18px max(12px, var(--safe-left));
11371137
}
11381138
.u-bub {
11391139
max-width: min(88%, calc(100vw - 52px));

apps/kimi-web/src/components/chat/Composer.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2173,9 +2173,9 @@ function selectModel(modelId: string): void {
21732173
.composer {
21742174
padding:
21752175
9px
2176-
var(--dock-inline-right, max(12px, env(safe-area-inset-right)))
2177-
max(24px, env(safe-area-inset-bottom))
2178-
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
2176+
var(--dock-inline-right, max(12px, var(--safe-right)))
2177+
max(24px, var(--safe-bottom))
2178+
var(--dock-inline-left, max(12px, var(--safe-left)));
21792179
}
21802180
.composer-card {
21812181
--composer-send-size: 36px;

apps/kimi-web/src/components/chat/ConversationPane.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,14 @@ function onKeyDown(event: KeyboardEvent): void {
11721172
}
11731173
}
11741174
1175+
// When the on-screen keyboard opens, browsers without interactive-widget support
1176+
// fire a visualViewport resize instead of shrinking the layout viewport. Re-follow
1177+
// the tail so the latest turn stays visible above the keyboard. No-op while the
1178+
// user has manually scrolled away (following === false).
1179+
function onVisualViewportResize(): void {
1180+
if (following.value) scheduleFollow();
1181+
}
1182+
11751183
onMounted(() => {
11761184
nextTick(() => {
11771185
if (typeof MutationObserver === 'function') {
@@ -1203,6 +1211,7 @@ onMounted(() => {
12031211
document.addEventListener('visibilitychange', onVisibilityChange);
12041212
document.addEventListener('keydown', onKeyDown);
12051213
}
1214+
window.visualViewport?.addEventListener('resize', onVisualViewportResize);
12061215
});
12071216
});
12081217
@@ -1222,6 +1231,7 @@ onUnmounted(() => {
12221231
document.removeEventListener('visibilitychange', onVisibilityChange);
12231232
document.removeEventListener('keydown', onKeyDown);
12241233
}
1234+
window.visualViewport?.removeEventListener('resize', onVisualViewportResize);
12251235
});
12261236
12271237
function focusComposer(): void {

apps/kimi-web/src/components/dialogs/BottomSheet.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ onUnmounted(() => {
145145
min-height: 0;
146146
overflow-y: auto;
147147
-webkit-overflow-scrolling: touch;
148-
padding-bottom: max(10px, env(safe-area-inset-bottom));
148+
padding-bottom: max(16px, var(--safe-bottom));
149149
}
150150
151151
/* Slide-up + fade transition for the whole sheet (scrim fades, panel slides). */

apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -585,11 +585,11 @@ watch(
585585
align-items: flex-start;
586586
gap: 10px;
587587
min-width: 0;
588-
padding: 14px max(14px, env(safe-area-inset-right)) 14px max(14px, env(safe-area-inset-left));
588+
padding: 14px max(14px, var(--safe-right)) 14px max(14px, var(--safe-left));
589589
}
590590
.group-title {
591-
padding-left: max(14px, env(safe-area-inset-left));
592-
padding-right: max(14px, env(safe-area-inset-right));
591+
padding-left: max(14px, var(--safe-left));
592+
padding-right: max(14px, var(--safe-right));
593593
}
594594
.srow-main {
595595
flex: 1 1 auto;

0 commit comments

Comments
 (0)