fix(kimi-web): improve mobile safe-area handling

This commit is contained in:
qer 2026-07-07 13:26:55 +08:00
parent 166e404a92
commit 2e1cceb2c7
11 changed files with 74 additions and 24 deletions

View file

@ -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)" />

View file

@ -1155,10 +1155,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;

View file

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

View file

@ -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';
@ -87,6 +87,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
@ -124,17 +125,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"
@ -357,12 +377,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;

View file

@ -1157,7 +1157,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));

View file

@ -1941,9 +1941,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 {
border-radius: var(--radius-xl);

View file

@ -944,6 +944,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(false);
}
onMounted(() => {
nextTick(() => {
if (typeof MutationObserver === 'function') {
@ -974,6 +982,7 @@ onMounted(() => {
document.addEventListener('visibilitychange', onVisibilityChange);
document.addEventListener('keydown', onKeyDown);
}
window.visualViewport?.addEventListener('resize', onVisualViewportResize);
});
});
@ -992,6 +1001,7 @@ onUnmounted(() => {
document.removeEventListener('visibilitychange', onVisibilityChange);
document.removeEventListener('keydown', onKeyDown);
}
window.visualViewport?.removeEventListener('resize', onVisualViewportResize);
});
function focusComposer(): void {

View file

@ -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). */

View file

@ -593,11 +593,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;

View file

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

View file

@ -213,6 +213,24 @@ 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. */
--dock-h: 0px;
}
/* -- icon primitive (design-system §02) -------------------------------------
Shared by the <Icon> component and iconSvg() v-html strings (lib/icons.ts).
Icons are Remix Icon (fill="currentColor" on a 24x24 grid), so colour follows