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
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>
178 lines
5.7 KiB
TypeScript
178 lines
5.7 KiB
TypeScript
import React from 'react';
|
|
import ReactDOM from 'react-dom/client';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import {
|
|
DaemonWorkspaceProvider,
|
|
DaemonSessionProvider,
|
|
} from '@qwen-code/webui/daemon-react-sdk';
|
|
import { App } from './App';
|
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
|
import { RootErrorFallback } from './components/RootErrorFallback';
|
|
import {
|
|
getDaemonBaseUrl,
|
|
getDaemonToken,
|
|
removeDaemonTokenFromUrl,
|
|
waitForDaemonTokenMessage,
|
|
} from './config/daemon';
|
|
import { normalizeLanguage, type WebShellLanguage } from './i18n';
|
|
import { WebShellThemeId, type WebShellTheme } from './themeContext';
|
|
import 'katex/dist/katex.min.css';
|
|
import './styles/standalone.css';
|
|
|
|
const DAEMON_BASE_URL = getDaemonBaseUrl();
|
|
|
|
const LANGUAGE_STORAGE_KEY = 'qwen-code-web-shell-language';
|
|
const THEME_STORAGE_KEY = 'qwen-code-web-shell-theme';
|
|
|
|
function parseTheme(value: string | null): WebShellTheme | undefined {
|
|
if (value === WebShellThemeId.Dark || value === WebShellThemeId.Light) {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function getThemeFromUrl(): WebShellTheme | undefined {
|
|
const theme = new URLSearchParams(window.location.search).get('theme');
|
|
return parseTheme(theme);
|
|
}
|
|
|
|
function readStoredTheme(): WebShellTheme | undefined {
|
|
try {
|
|
return parseTheme(window.localStorage.getItem(THEME_STORAGE_KEY));
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function storeTheme(theme: WebShellTheme): void {
|
|
try {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
} catch {
|
|
// Ignore storage failures in private browsing or locked-down browsers.
|
|
}
|
|
}
|
|
|
|
function getInitialTheme(): WebShellTheme {
|
|
return getThemeFromUrl() ?? readStoredTheme() ?? WebShellThemeId.Dark;
|
|
}
|
|
|
|
function readStoredLanguage(): WebShellLanguage | undefined {
|
|
try {
|
|
const raw = window.localStorage.getItem(LANGUAGE_STORAGE_KEY);
|
|
return raw ? normalizeLanguage(raw) : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function storeLanguage(language: WebShellLanguage): void {
|
|
try {
|
|
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language);
|
|
} catch {
|
|
// Ignore storage failures in private browsing or locked-down browsers.
|
|
}
|
|
}
|
|
|
|
function getInitialLanguage(): WebShellLanguage {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const raw = params.get('language') ?? params.get('lang');
|
|
if (raw) return normalizeLanguage(raw);
|
|
return normalizeLanguage(readStoredLanguage() ?? navigator.language);
|
|
}
|
|
|
|
function getSessionIdFromUrl(): string | undefined {
|
|
const match = window.location.pathname.match(/\/session\/([^/]+)/);
|
|
if (!match) return undefined;
|
|
try {
|
|
return decodeURIComponent(match[1]);
|
|
} catch {
|
|
return 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');
|
|
}
|
|
window.history.replaceState(null, '', url);
|
|
}
|
|
|
|
function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
|
|
const [theme, setTheme] = useState<WebShellTheme>(() => getInitialTheme());
|
|
const [language, setLanguage] = useState<WebShellLanguage>(() =>
|
|
getInitialLanguage(),
|
|
);
|
|
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);
|
|
}, []);
|
|
const handleLanguageChange = useCallback((nextLanguage: WebShellLanguage) => {
|
|
setLanguage(nextLanguage);
|
|
storeLanguage(nextLanguage);
|
|
}, []);
|
|
const handleSessionIdChange = useCallback((nextSessionId?: string) => {
|
|
replaceStandaloneSessionUrl(nextSessionId);
|
|
}, []);
|
|
|
|
return (
|
|
<ErrorBoundary
|
|
label="web-shell-root"
|
|
fallback={(error, reset) => (
|
|
<RootErrorFallback error={error} onRetry={reset} language={language} />
|
|
)}
|
|
>
|
|
<DaemonWorkspaceProvider baseUrl={baseUrl} token={daemonToken}>
|
|
<DaemonSessionProvider
|
|
key={sessionId ?? 'new'}
|
|
sessionId={sessionId}
|
|
suppressOwnUserEcho
|
|
>
|
|
<App
|
|
theme={theme}
|
|
onThemeChange={handleThemeChange}
|
|
language={language}
|
|
onLanguageChange={handleLanguageChange}
|
|
onSessionIdChange={handleSessionIdChange}
|
|
sidebar
|
|
compactThinking
|
|
/>
|
|
</DaemonSessionProvider>
|
|
</DaemonWorkspaceProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
async function main() {
|
|
const daemonToken = getDaemonToken() ?? (await waitForDaemonTokenMessage());
|
|
removeDaemonTokenFromUrl();
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
<React.StrictMode>
|
|
<StandaloneApp daemonToken={daemonToken} />
|
|
</React.StrictMode>,
|
|
);
|
|
}
|
|
|
|
void main();
|