Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mobile-safe-area.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion apps/kimi-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="64x64" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" />
Expand Down
52 changes: 48 additions & 4 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -154,13 +176,26 @@ 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);
});

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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-web/src/components/WarningToasts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a real fallback when the dock height is unset

Because style.css initializes --dock-h to 0px, the var(--dock-h, 76px) fallback here never selects 76px. On mobile states where ChatDock has not mounted yet or is not rendered (for example auth/empty-session warning toasts), the stack is placed only 8px above the viewport bottom, losing the previous safe-area/home-indicator clearance and potentially covering the toast actions. Consider leaving --dock-h unset until measured or using an explicit max()/safe-bottom fallback.

Useful? React with 👍 / 👎.

width: auto;
max-height: 50vh;
}
Expand Down
34 changes: 26 additions & 8 deletions apps/kimi-web/src/components/chat/ChatDock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<!-- pending question/approval cards, and the composer. Only rendered inside a -->
<!-- chat-pane group so it never leaks into files/tasks/preview/btw panes. -->
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoView, UIQuestion } from '../../types';
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
Expand Down Expand Up @@ -91,6 +91,7 @@ const composerRef = ref<{
} | null>(null);
const workPanelRef = ref<HTMLElement | null>(null);
const workbarRef = ref<HTMLElement | null>(null);
const dockRef = ref<HTMLElement | null>(null);

function loadForEdit(value: string): boolean {
// The nested Composer is only rendered in ChatDock's v-else — when a pending
Expand Down Expand Up @@ -128,17 +129,36 @@ watch(
{ immediate: true },
);

let dockResizeObserver: ResizeObserver | null = null;

function publishDockHeight(): void {
// Border-box height of the dock, exposed so fixed overlays (e.g. toasts) can
// anchor just above the composer. offsetHeight includes the dock's own
// safe-area padding, so consumers don't need to add safe-bottom again.
const height = dockRef.value?.offsetHeight ?? 0;
document.documentElement.style.setProperty('--dock-h', `${height}px`);
}

onMounted(() => {
if (typeof ResizeObserver !== 'function' || !dockRef.value) return;
dockResizeObserver = new ResizeObserver(publishDockHeight);
dockResizeObserver.observe(dockRef.value);
publishDockHeight();
});

onUnmounted(() => {
if (typeof document !== 'undefined') {
document.removeEventListener('mousedown', onDocumentMouseDown, true);
}
dockResizeObserver?.disconnect();
dockResizeObserver = null;
});

defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });
</script>

<template>
<div class="chat-dock" :class="[mobile ? 'align-mobile' : 'align-center']" @click.stop>
<div ref="dockRef" class="chat-dock" :class="[mobile ? 'align-mobile' : 'align-center']" @click.stop>
<Transition name="dock-panel">
<div
ref="workPanelRef"
Expand Down Expand Up @@ -363,12 +383,10 @@ defineExpose({ loadForEdit, loadAttachmentsForEdit, focus });

@media (max-width: 640px) {
.chat-dock {
--dock-inline-left: max(12px, env(safe-area-inset-left));
--dock-inline-right: max(12px, env(safe-area-inset-right));
}
.chat-dock.align-mobile {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
/* Inline (landscape) safe-area lives here only; the inner composer /
workbar read --dock-inline-* so the inset is applied exactly once. */
--dock-inline-left: max(12px, var(--safe-left));
--dock-inline-right: max(12px, var(--safe-right));
}
.dock-work-panel {
left: 10px;
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-web/src/components/chat/ChatPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1133,7 +1133,7 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
.chat {
box-sizing: border-box;
width: 100%;
padding: 14px max(12px, env(safe-area-inset-right)) 18px max(12px, env(safe-area-inset-left));
padding: 14px max(12px, var(--safe-right)) 18px max(12px, var(--safe-left));
}
.u-bub {
max-width: min(88%, calc(100vw - 52px));
Expand Down
6 changes: 3 additions & 3 deletions apps/kimi-web/src/components/chat/Composer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2173,9 +2173,9 @@ function selectModel(modelId: string): void {
.composer {
padding:
9px
var(--dock-inline-right, max(12px, env(safe-area-inset-right)))
max(24px, env(safe-area-inset-bottom))
var(--dock-inline-left, max(12px, env(safe-area-inset-left)));
var(--dock-inline-right, max(12px, var(--safe-right)))
max(24px, var(--safe-bottom))
var(--dock-inline-left, max(12px, var(--safe-left)));
}
.composer-card {
--composer-send-size: 36px;
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-web/src/components/chat/ConversationPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1172,6 +1172,14 @@ function onKeyDown(event: KeyboardEvent): void {
}
}

// When the on-screen keyboard opens, browsers without interactive-widget support
// fire a visualViewport resize instead of shrinking the layout viewport. Re-follow
// the tail so the latest turn stays visible above the keyboard. No-op while the
// user has manually scrolled away (following === false).
function onVisualViewportResize(): void {
if (following.value) scheduleFollow();
}

onMounted(() => {
nextTick(() => {
if (typeof MutationObserver === 'function') {
Expand Down Expand Up @@ -1203,6 +1211,7 @@ onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange);
document.addEventListener('keydown', onKeyDown);
}
window.visualViewport?.addEventListener('resize', onVisualViewportResize);
});
});

Expand All @@ -1222,6 +1231,7 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', onVisibilityChange);
document.removeEventListener('keydown', onKeyDown);
}
window.visualViewport?.removeEventListener('resize', onVisualViewportResize);
});

function focusComposer(): void {
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-web/src/components/dialogs/BottomSheet.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ onUnmounted(() => {
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: max(10px, env(safe-area-inset-bottom));
padding-bottom: max(16px, var(--safe-bottom));
}

/* Slide-up + fade transition for the whole sheet (scrim fades, panel slides). */
Expand Down
6 changes: 3 additions & 3 deletions apps/kimi-web/src/components/mobile/MobileSettingsSheet.vue
Original file line number Diff line number Diff line change
Expand Up @@ -585,11 +585,11 @@ watch(
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 14px max(14px, env(safe-area-inset-right)) 14px max(14px, env(safe-area-inset-left));
padding: 14px max(14px, var(--safe-right)) 14px max(14px, var(--safe-left));
}
.group-title {
padding-left: max(14px, env(safe-area-inset-left));
padding-right: max(14px, env(safe-area-inset-right));
padding-left: max(14px, var(--safe-left));
padding-right: max(14px, var(--safe-right));
}
.srow-main {
flex: 1 1 auto;
Expand Down
6 changes: 4 additions & 2 deletions apps/kimi-web/src/components/mobile/MobileTopBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,11 @@ const statusText = computed<string>(() =>
display: flex;
align-items: center;
gap: 10px;
height: 50px;
/* Grow the bar by the top inset so the 50px content row stays below the
status bar / notch in standalone PWA mode and landscape. */
height: calc(50px + var(--safe-top));
flex: none;
padding: 0 12px;
padding: var(--safe-top) max(12px, var(--safe-right)) 0 max(12px, var(--safe-left));
border-bottom: 1px solid var(--color-line);
background: var(--color-bg);
font-family: var(--font-ui);
Expand Down
19 changes: 19 additions & 0 deletions apps/kimi-web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,25 @@ summary {
color-scheme: light dark;
}

/* Safe-area insets + chat-dock metrics ---------------------------------------
Centralised so mobile layout reads one consistent source instead of each
component calling env(safe-area-inset-*) with its own fallback. Falls back to
0px where the UA doesn't expose insets (most desktops, older Android);
pair with max(min, var(--safe-bottom)) so gestures / home indicators keep a
minimum gutter even when the inset reports 0. The mobile footer (composer),
bottom sheets and the toast layer all consume these. */
:root {
--safe-top: env(safe-area-inset-top, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
/* Border-box height of the chat dock, written by ChatDock via ResizeObserver.
Used to anchor fixed overlays (toasts, floating hints) just above the
composer — dynamic so multi-line input growth keeps them clear. Left
undeclared on purpose: consumers use a fallback (e.g. the assumed
single-line dock height) on pages where ChatDock is not mounted. */
}

/* -- icon primitive (design-system §02) -------------------------------------
Applied to every design-system icon: the <Icon> component output
(components/ui/Icon.vue) and the iconSvg() v-html strings (lib/icons.ts).
Expand Down
Loading