mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
fix(web-shell): mobile UX — safe areas, overscroll, native-app feel (#6142)
* fix(web-shell): mobile UX — safe areas, overscroll, native-app feel
The Web Shell felt like a regular web page on iPhone:
- white bands at the top (status bar) and bottom (home indicator) when
scrolling on notched devices,
- iOS Safari's rubber-band overscroll briefly exposed the default white
browser canvas,
- 300ms tap delay and the blue tap-flash gave away that buttons were
HTML elements,
- long-pressing UI chrome (hamburger, composer buttons) popped the
browser's "Copy / Look Up" callout,
- tapping the composer auto-zoomed the viewport because the editor
font-size was 14px (below iOS's 16px threshold),
- the soft keyboard pushed the composer off-screen instead of resizing
the content area.
This commit fixes all of the above so the Web Shell reads as a native
chat app on mobile, while preserving accessibility (users can still
pinch-zoom message content and long-press to copy code).
Safe-area / overscroll fixes:
- index.html: add `viewport-fit=cover` so content extends behind the
status bar and home indicator; add a `theme-color` meta; add an
inline script that runs before first paint to read the stored theme
and apply `.theme-dark` / `.theme-light` to `<html>` so the canvas
background matches the app theme from the very first frame.
- main.tsx: add a `useEffect` that keeps `<html>` class and
`<meta theme-color>` in sync when the React theme changes (covers
the `?theme=` URL parameter and in-app toggling).
- standalone.css: set html/body background per theme class and add
`overscroll-behavior-y: none` to suppress the pull-to-refresh bounce
(the chat pane handles its own scroll internally).
- App.module.css: add `padding-top: env(safe-area-inset-top)` and
`padding-bottom: env(safe-area-inset-bottom)` on `.app`. On desktop
and non-notched devices every inset evaluates to 0 so nothing changes.
Native-app feel:
- index.html: add `interactive-widget=resizes-content` to the viewport
meta so the iOS soft keyboard resizes the content area instead of
panning / zooming the viewport.
- standalone.css: set `touch-action: manipulation` on html/body to
remove the 300ms tap delay and disable double-tap zoom (we
intentionally do NOT set `user-scalable=no` / `maximum-scale=1` —
those break WCAG 1.4.4 and iOS 10+ already ignores them); add
`-webkit-tap-highlight-color: transparent` to remove the iOS blue
flash; add `text-size-adjust: 100%` to stop iOS bumping the font
size on orientation change; apply `user-select: none` +
`-webkit-touch-callout: none` to all descendants of html so long
presses on UI chrome no longer trigger the browser's callout menu;
re-enable selection on message content, the CodeMirror editor
surface, and native inputs via a `:where()` rule.
- standalone.css: add an `@supports` + `@media` rule that bumps the
composer / input font-size to 16px on iPhone only, preventing iOS
Safari's auto-zoom on focus while preserving the 14px desktop look.
- MessageItem.tsx: wrap each rendered message in a
`data-user-selectable="true"` div so users can still long-press /
drag-select reply text (the blanket `user-select: none` on
`html *` would otherwise disable selection on message bodies too).
Tested manually on iPhone over `qwen serve --hostname 0.0.0.0`.
Authored-on: Qwen Code Web Shell (mobile) ~(¯▽¯~)~
Signed-off-by: pomelo-nwu <czynwu@outlook.com>
* fix(web-shell): address review feedback — specificity, safe-area, minor fixes
- Fix :where() specificity bug: plain [data-user-selectable] at (0,1,0)
beats html * at (0,0,1), restoring text selection on message bodies
- Add left/right safe-area insets for landscape notch phones
- Replace silent outer catch with console.warn for theme-init script
- Update MessageItem comment to match code (wrapper now always applied)
- Add Android-only comment for interactive-widget=resizes-content
- Use explicit theme class removal instead of stripping all theme-* prefixes
* fix(web-shell): increase mobile font-size specificity, add drawer safe-area padding
- Scope mobile 16px font override under .app (0,3,0) to beat
.editorArea .cm-content (0,2,0) from ChatEditor.module.css
- Add safe-area top/bottom padding to mobileDrawerOpen so sidebar
content stays clear of status bar notch and home indicator
* fix(web-shell): scope font-size override to composer, add dialog selection
- Replace dead `.app` selector (CSS module hashes the class name) with
`#root [data-composer]` at specificity (1,2,0), reliably beating the
EditorView.theme `.cm-content` at (0,2,0). Add `data-composer`
attribute to the editorShell div in ChatEditor.tsx.
- Add `[role='dialog']` and `[role='dialog'] *` to the selection
re-enable block so tool-approval overlays, MCP config, theme picker,
and other portal dialogs remain selectable on mobile.
* fix(web-shell): address review round 2 — drawer, inputs, color-scheme, theme URL
- Exclude mobile drawer from `[role='dialog']` selection re-enable
(drawer gets role=dialog when open, which would undo user-select:none
on sidebar controls)
- Add horizontal safe-area padding to the fixed drawer for landscape
notch iPhones
- Strip `?theme=` from URL via replaceState to prevent bookmarked /
shared URLs from permanently overriding stored theme preference
- Broaden font-size 16px override to cover all text inputs on iPhone
(dialog inputs, sidebar search, etc.), not just the composer
- Add `color-scheme: dark/light` to theme blocks so native form
controls (scrollbars, selection handles, autofill) match the theme
* fix(web-shell): review round 3 — touch-only user-select, iPad auto-zoom, URL params
- Scope `html * { user-select: none }` to `@media (hover: none) and
(pointer: coarse)` so desktop users retain normal text selection in
sidebar, error messages, and toasts
- Replace deprecated `max-device-width` media query with
`(hover: none) and (pointer: coarse)` for auto-zoom prevention,
covering iPad in addition to iPhone
- Strip `?language=` and `?lang=` from URL alongside `?theme=` to
prevent bookmarked URLs from permanently overriding stored preferences
- Fix stale `:where()` reference in comment
---------
Signed-off-by: pomelo-nwu <czynwu@outlook.com>
Co-authored-by: 易良 <1204183885@qq.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
75645caf47
commit
848386a624
6 changed files with 205 additions and 9 deletions
|
|
@ -13,6 +13,18 @@
|
|||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
/*
|
||||
* Safe-area padding for mobile devices with notch / home indicator.
|
||||
* With `viewport-fit=cover` in index.html the page extends behind the
|
||||
* status-bar, home-indicator, and landscape notch regions; these env()
|
||||
* insets push the content back into the visible safe area. On desktop
|
||||
* or phones without notches every inset evaluates to 0 so nothing
|
||||
* changes.
|
||||
*/
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono);
|
||||
|
|
@ -101,6 +113,14 @@
|
|||
pointer-events: auto;
|
||||
/* Show immediately on open; only the close path needs the delay above. */
|
||||
transition-delay: 0s;
|
||||
/* With `viewport-fit=cover`, the fixed drawer extends behind the status
|
||||
bar, home indicator, and landscape notch. Push the sidebar content
|
||||
into the safe area while keeping the backdrop full-screen (it is a
|
||||
sibling, not a child). */
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
.mobileBackdrop {
|
||||
|
|
|
|||
|
|
@ -1261,7 +1261,7 @@ export const ChatEditor = memo(
|
|||
const showCancelButton = isRunning && !core.hasContent;
|
||||
|
||||
return (
|
||||
<div className={styles.editorShell}>
|
||||
<div className={styles.editorShell} data-composer>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.container}
|
||||
|
|
|
|||
|
|
@ -149,21 +149,29 @@ export const MessageItem = memo(function MessageItem({
|
|||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
// Re-enable text selection on every message row so users can long-press /
|
||||
// drag-select reply text. The blanket `html * { user-select: none }` in
|
||||
// standalone.css disables selection on UI chrome (native-app feel); this
|
||||
// attribute opts the message subtree back in, including descendants
|
||||
// (Markdown body, code blocks, tool panels, sub-messages).
|
||||
const selectableSafeBody = <div data-user-selectable="true">{safeBody}</div>;
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
if (showAssistantActions) {
|
||||
return safeBody;
|
||||
return selectableSafeBody;
|
||||
}
|
||||
return (
|
||||
<MessageTimestamp timestamp={message.timestamp}>
|
||||
{safeBody}
|
||||
{selectableSafeBody}
|
||||
</MessageTimestamp>
|
||||
);
|
||||
}
|
||||
|
||||
// The cancellation marker is a right-aligned, full-width turn-terminal row; a
|
||||
// hover timestamp would overlap its text, so render it without the wrapper.
|
||||
// The cancellation marker is a right-aligned, full-width turn-terminal row;
|
||||
// a hover timestamp would overlap its text, so skip the MessageTimestamp
|
||||
// wrapper. The data-user-selectable div is still applied for consistency.
|
||||
if (message.role === 'system' && message.source === 'prompt_cancelled') {
|
||||
return safeBody;
|
||||
return selectableSafeBody;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -173,7 +181,7 @@ export const MessageItem = memo(function MessageItem({
|
|||
copyText={message.role === 'user' ? message.content : undefined}
|
||||
copyTitle="Copy"
|
||||
>
|
||||
{safeBody}
|
||||
{selectableSafeBody}
|
||||
</MessageTimestamp>
|
||||
);
|
||||
}, areMessageItemPropsEqual);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,33 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!--
|
||||
`interactive-widget=resizes-content` is Chrome-on-Android-only (108+);
|
||||
iOS Safari silently ignores it — the virtual keyboard always overlays
|
||||
the viewport there. Keyboard-aware layout on iOS requires the
|
||||
VirtualKeyboard API or `visualViewport` resize events instead.
|
||||
-->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
|
||||
/>
|
||||
<!--
|
||||
Status bar color follows the active theme; the inline script below
|
||||
updates this meta tag in the same tick it applies the theme class so
|
||||
mobile browsers never flash the default (white) bar.
|
||||
-->
|
||||
<meta name="theme-color" content="#0d0d0d" />
|
||||
<title>Qwen Code Web chat</title>
|
||||
<!--
|
||||
Apply the stored theme class + theme-color before first paint so the
|
||||
<html> background matches the app theme on mobile. Without this, the
|
||||
iOS Safari rubber-band overscroll and the status-bar / home-indicator
|
||||
safe areas briefly show the default white canvas. Storage key mirrors
|
||||
THEME_STORAGE_KEY in main.tsx.
|
||||
-->
|
||||
<script>
|
||||
!function(){try{var k='qwen-code-web-shell-theme',p=new URLSearchParams(location.search),t=p.get('theme');if(t!=='dark'&&t!=='light'){try{t=localStorage.getItem(k)}catch(_){t=null}}if(t!=='dark'&&t!=='light')t='dark';document.documentElement.classList.add('theme-'+t);var m=document.querySelector('meta[name=theme-color]');if(m)m.setAttribute('content',t==='light'?'#ffffff':'#0d0d0d')}catch(e){console.warn('theme-init:',e)}}();
|
||||
</script>
|
||||
<!--
|
||||
Favicon is inlined as a data: URI rather than a /favicon.svg file because
|
||||
the daemon's static server only exposes /assets/* and / (see
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
DaemonWorkspaceProvider,
|
||||
DaemonSessionProvider,
|
||||
|
|
@ -93,6 +93,11 @@ function getSessionIdFromUrl(): string | undefined {
|
|||
function replaceStandaloneSessionUrl(sessionId: string | undefined): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.pathname = sessionId ? `/session/${encodeURIComponent(sessionId)}` : '/';
|
||||
// Strip one-shot query params so bookmarked / shared URLs do not
|
||||
// permanently override stored preferences on every page load.
|
||||
url.searchParams.delete('theme');
|
||||
url.searchParams.delete('language');
|
||||
url.searchParams.delete('lang');
|
||||
if (!import.meta.env.DEV) {
|
||||
url.searchParams.delete('token');
|
||||
url.searchParams.delete('daemon');
|
||||
|
|
@ -107,6 +112,18 @@ function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
|
|||
);
|
||||
const [sessionId] = useState<string | undefined>(() => getSessionIdFromUrl());
|
||||
const baseUrl = DAEMON_BASE_URL || window.location.origin;
|
||||
// Keep the <html> theme class and <meta name="theme-color"> in sync with
|
||||
// the React theme so mobile status bars / overscroll backgrounds stay
|
||||
// consistent when the user toggles or when ?theme= lands via URL.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('theme-dark', 'theme-light');
|
||||
root.classList.add(`theme-${theme}`);
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
meta.setAttribute('content', theme === 'light' ? '#ffffff' : '#0d0d0d');
|
||||
}
|
||||
}, [theme]);
|
||||
const handleThemeChange = useCallback((nextTheme: WebShellTheme) => {
|
||||
setTheme(nextTheme);
|
||||
storeTheme(nextTheme);
|
||||
|
|
|
|||
|
|
@ -4,3 +4,129 @@ body,
|
|||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mobile overscroll / safe-area fix:
|
||||
*
|
||||
* On iOS Safari the rubber-band pull exposes whatever sits behind the page
|
||||
* canvas. With `viewport-fit=cover` the status-bar and home-indicator areas
|
||||
* also reveal the html background. Paint html/body with the active theme's
|
||||
* background and pin overscroll so the white browser chrome never peeks
|
||||
* through. The .theme-* classes are applied to <html> by the inline script
|
||||
* in index.html (and kept in sync by StandaloneApp's useEffect).
|
||||
*
|
||||
* `overscroll-behavior-y: none` disables the pull-to-refresh / bounce; the
|
||||
* chat pane handles its own scroll internally so this does not affect chat
|
||||
* scrolling.
|
||||
*/
|
||||
html,
|
||||
body {
|
||||
background: #0d0d0d;
|
||||
color: #fafafa;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
html.theme-light,
|
||||
html.theme-light body {
|
||||
background: #ffffff;
|
||||
color: #0a0a0b;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html.theme-dark,
|
||||
html.theme-dark body {
|
||||
background: #0d0d0d;
|
||||
color: #fafafa;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/*
|
||||
* Native-app feel (P0):
|
||||
*
|
||||
* `touch-action: manipulation` removes the 300ms tap delay and disables
|
||||
* double-tap zoom — the two main giveaways that a page is "just a website".
|
||||
* We do NOT set `user-scalable=no` / `maximum-scale=1` because those break
|
||||
* WCAG 1.4.4 and iOS 10+ already ignores them; users can still pinch-zoom
|
||||
* message content for accessibility.
|
||||
*
|
||||
* `-webkit-tap-highlight-color: transparent` removes the blue flash iOS
|
||||
* shows on tap. `-webkit-text-size-adjust: 100%` stops iOS from silently
|
||||
* bumping font size on orientation change.
|
||||
*
|
||||
* `user-select: none` + `-webkit-touch-callout: none` suppress the
|
||||
* long-press "Copy / Look Up" callout and accidental text selection on
|
||||
* UI chrome (buttons, nav, composer shell). Scoped to touch devices via
|
||||
* `@media (hover: none) and (pointer: coarse)` so desktop users retain
|
||||
* normal text selection in sidebar, error messages, toasts, etc.
|
||||
* Message content and the editor re-enable both via
|
||||
* `[data-user-selectable]` below so users can still copy code and
|
||||
* long-press links inside replies.
|
||||
*/
|
||||
html,
|
||||
body {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
html *,
|
||||
html *::before,
|
||||
html *::after {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Re-enable selection on user-facing text regions: message bodies, code
|
||||
* blocks, the editor's contenteditable surface, any dialog / panel whose
|
||||
* root opts in via `data-user-selectable`, and all ARIA dialogs (tool
|
||||
* approval, MCP config, theme picker, etc.). Plain attribute selectors
|
||||
* have specificity (0,1,0) which beats the blanket `html *` at (0,0,1).
|
||||
*/
|
||||
[data-user-selectable],
|
||||
[data-user-selectable] *,
|
||||
[role='dialog']:not([data-mobile-drawer]),
|
||||
[role='dialog']:not([data-mobile-drawer]) *,
|
||||
.cm-content,
|
||||
.cm-line,
|
||||
input,
|
||||
textarea,
|
||||
[contenteditable='true'] {
|
||||
-webkit-touch-callout: default;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/*
|
||||
* iOS Safari auto-zooms any <input> / <textarea> / contenteditable whose
|
||||
* computed font-size is < 16px when focused. The composer editor sits at
|
||||
* 14px in the desktop theme (EditorView.theme at specificity 0,2,0 and
|
||||
* `.editorArea .cm-content` in ChatEditor.module.css also at 0,2,0), so
|
||||
* bump it to 16px on all touch devices (iPhone, iPad). The composer uses
|
||||
* `#root [data-composer]` at specificity (1,2,0) to beat the EditorView
|
||||
* theme. Other text inputs use `#root input[type=...]` at (1,1,0).
|
||||
* `@supports (-webkit-touch-callout: none)` scopes to WebKit (Safari);
|
||||
* `(hover: none) and (pointer: coarse)` matches touch devices including
|
||||
* iPad — and avoids the deprecated `max-device-width`.
|
||||
*/
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
#root [data-composer] .cm-content,
|
||||
#root [data-composer] .cm-line,
|
||||
#root input[type='text'],
|
||||
#root input[type='search'],
|
||||
#root input[type='password'],
|
||||
#root input[type='email'],
|
||||
#root input[type='url'],
|
||||
#root input[type='tel'],
|
||||
#root input[type='number'],
|
||||
#root textarea,
|
||||
#root [contenteditable='true'] {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue