mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 23:25:28 +00:00
* fix(web-shell): render a plain textarea composer on touch devices Mobile browsers could not type into the Web Shell composer (#5958): CodeMirror's contenteditable interacts poorly with virtual keyboards, and three non-gesture view.focus() calls claim activeElement on iOS without opening the keyboard, after which taps may never refocus the editor. On touch devices ('(hover: none) and (pointer: coarse)' plus maxTouchPoints > 0 — touch laptops keep the desktop editor) useComposerCore now skips creating an EditorView entirely and exposes a mobileComposer backend that ChatEditor renders as a controlled <textarea> at the same mount point. The internal submit pipeline was hoisted out of the editor-creation effect and accepts view: EditorView | null, so history, prompt building, tags, images, and slash/! text interpretation are shared unchanged between both backends. Enter inserts a newline natively; submission goes through the Send button. Programmatic (non-gesture) focus is additionally suppressed on coarse-pointer devices even when CodeMirror is forced, and ?composer=textarea|codemirror serves as a debugging and rollback escape hatch. The choice is frozen at mount so a mid-session flip cannot drop the draft. Known textarea-backend degradations (commands still work as typed text): no slash/@ completion menus, no inline tag chips (fall back to the top placement), no history arrow navigation, no large-paste placeholders, and no followup Tab-accept. Fixes #5958 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web-shell): address review — textarea auto-grow, change notifications, caret restore Address the five inline review suggestions on #7587: - Auto-grow the mobile textarea with its content, capped by the computed CSS max-height (so --chat-editor-input-max-height overrides stay authoritative). Previously rows={1} plus resize:none meant multi-line drafts scrolled inside ~1.5 visible lines and the CSS max-height was dead. Asserted in the mobile e2e spec via bounding-box growth. - Fire onInputTextChange from setMobileText, matching the CodeMirror updateListener contract: programmatic draft changes (setText, history restore, post-submit clear) now notify parent trackers too. handleMobileChange delegates to setMobileText. - Restore the caret after mobile insertText: a controlled textarea resets the caret to the end on value change; setSelectionRange puts it back after React re-renders (rAF with a setTimeout fallback), matching the CodeMirror path's explicit selection anchor. - Cover the mobile submitSearchMatch path: select a history match, submit through the shared pipeline, draft cleared. - Cover the ChatEditor mobile quick-action gating: the history quick action opens the search UI (never dispatches into a missing EditorView) and the keyboard shortcut hints grid is hidden on the mobile composer with a desktop control. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web-shell): decide the touch composer by media query alone Independent macOS verification on #7587 found that Playwright's stock WebKit iPhone profiles match '(hover: none) and (pointer: coarse)' but report navigator.maxTouchPoints === 0, so the automatic detection selected CodeMirror under unmodified WebKit emulation. The maxTouchPoints requirement added nothing the AND media query does not already provide: touch laptops are excluded by the query itself (their primary pointer hovers and is fine), and the only devices that match the query with zero touch points are emulated profiles and TV-style browsers, where the plain textarea is a safe fallback. Dropping it makes stock iPhone/WebKit Playwright runs exercise the automatic detection branch. Real-device behavior is unchanged: phones and tablets match the query and report touch points either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web-shell): keep the mobile textarea scrollable past its height cap As .editorArea's last child the textarea inherited `overflow: clip` from the `.editorArea > :last-child` wrapper rule (written for the CodeMirror container, whose inner .cm-scroller does the scrolling). `clip` also forbids programmatic scrolling, so once auto-grow reached the CSS max-height, content beyond the cap was unreachable — scrollTop stayed pinned at 0. Override with `overflow-y: auto` via `.editorArea > textarea.mobileTextarea` (the extra type selector outweighs the wrapper rule's specificity). New mobile e2e regression fills 20 lines, asserts growth stops at the computed 300px cap, and verifies the overflow stays reachable: scrollHeight above clientHeight and scrollTop actually moving to the bottom — the exact probe from the review, which pinned at 0 before this fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: ComplexSimply <rudy.arrowsong@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
172 lines
5.6 KiB
TypeScript
172 lines
5.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import {
|
|
TOUCH_COMPOSER_QUERY,
|
|
isCoarsePointerDevice,
|
|
useIsTouchComposer,
|
|
} from './useIsTouchComposer';
|
|
|
|
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
|
|
const originalMatchMedia = window.matchMedia;
|
|
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(
|
|
Navigator.prototype,
|
|
'maxTouchPoints',
|
|
);
|
|
let root: Root | null = null;
|
|
let container: HTMLDivElement | null = null;
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container?.remove();
|
|
root = null;
|
|
container = null;
|
|
window.matchMedia = originalMatchMedia;
|
|
if (originalMaxTouchPoints) {
|
|
Object.defineProperty(
|
|
Navigator.prototype,
|
|
'maxTouchPoints',
|
|
originalMaxTouchPoints,
|
|
);
|
|
} else {
|
|
delete (navigator as unknown as Record<string, unknown>)['maxTouchPoints'];
|
|
}
|
|
window.history.replaceState({}, '', '/');
|
|
});
|
|
|
|
function installMatchMedia(matchesByQuery: Record<string, boolean>) {
|
|
const listeners: Array<{
|
|
query: string;
|
|
cb: (event: MediaQueryListEvent) => void;
|
|
}> = [];
|
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
matches: matchesByQuery[query] ?? false,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: (
|
|
_type: string,
|
|
cb: (event: MediaQueryListEvent) => void,
|
|
) => listeners.push({ query, cb }),
|
|
removeEventListener: () => {},
|
|
addListener: () => {},
|
|
removeListener: () => {},
|
|
dispatchEvent: () => true,
|
|
})) as unknown as typeof window.matchMedia;
|
|
return {
|
|
fire(query: string, matches: boolean) {
|
|
matchesByQuery[query] = matches;
|
|
act(() => {
|
|
listeners
|
|
.filter((l) => l.query === query)
|
|
.forEach((l) => l.cb({ matches } as MediaQueryListEvent));
|
|
});
|
|
},
|
|
listenerCount: () => listeners.length,
|
|
};
|
|
}
|
|
|
|
function setMaxTouchPoints(value: number) {
|
|
Object.defineProperty(navigator, 'maxTouchPoints', {
|
|
value,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
function renderHook(): { value: () => boolean } {
|
|
let latest = false;
|
|
function Probe() {
|
|
latest = useIsTouchComposer();
|
|
return null;
|
|
}
|
|
container = document.createElement('div');
|
|
document.body.appendChild(container);
|
|
act(() => {
|
|
root = createRoot(container!);
|
|
root.render(<Probe />);
|
|
});
|
|
return { value: () => latest };
|
|
}
|
|
|
|
describe('useIsTouchComposer', () => {
|
|
it('returns true when the coarse-pointer query matches and touch points exist', () => {
|
|
installMatchMedia({ [TOUCH_COMPOSER_QUERY]: true });
|
|
setMaxTouchPoints(5);
|
|
expect(renderHook().value()).toBe(true);
|
|
});
|
|
|
|
it('activates on coarse-pointer devices even without reported touch points', () => {
|
|
// Playwright's stock WebKit iPhone profiles match the media query but
|
|
// report maxTouchPoints === 0, and some TV browsers do the same. The
|
|
// media query alone decides: the textarea is a safe fallback wherever
|
|
// the primary pointer is coarse and cannot hover.
|
|
installMatchMedia({ [TOUCH_COMPOSER_QUERY]: true });
|
|
setMaxTouchPoints(0);
|
|
expect(renderHook().value()).toBe(true);
|
|
});
|
|
|
|
it('returns false on hover-capable touch devices (touch laptops)', () => {
|
|
// (hover: none) and (pointer: coarse) does not match a laptop with a
|
|
// touchscreen plus trackpad, even though maxTouchPoints > 0.
|
|
installMatchMedia({ [TOUCH_COMPOSER_QUERY]: false });
|
|
setMaxTouchPoints(10);
|
|
expect(renderHook().value()).toBe(false);
|
|
});
|
|
|
|
it('returns false when matchMedia is unavailable (SSR / jsdom default)', () => {
|
|
(window as unknown as Record<string, unknown>)['matchMedia'] = undefined;
|
|
setMaxTouchPoints(5);
|
|
expect(renderHook().value()).toBe(false);
|
|
});
|
|
|
|
it('honors ?composer=textarea as a force-on override', () => {
|
|
installMatchMedia({ [TOUCH_COMPOSER_QUERY]: false });
|
|
setMaxTouchPoints(0);
|
|
window.history.replaceState({}, '', '/?composer=textarea');
|
|
expect(renderHook().value()).toBe(true);
|
|
});
|
|
|
|
it('honors ?composer=codemirror as a force-off escape hatch on touch devices', () => {
|
|
installMatchMedia({ [TOUCH_COMPOSER_QUERY]: true });
|
|
setMaxTouchPoints(5);
|
|
window.history.replaceState({}, '', '/?composer=codemirror');
|
|
expect(renderHook().value()).toBe(false);
|
|
});
|
|
|
|
it('freezes the choice at mount and ignores later media changes', () => {
|
|
// Swapping editor backends mid-session would drop composer state, so the
|
|
// hook intentionally does not subscribe to media query changes.
|
|
const media = installMatchMedia({ [TOUCH_COMPOSER_QUERY]: false });
|
|
setMaxTouchPoints(0);
|
|
const probe = renderHook();
|
|
expect(probe.value()).toBe(false);
|
|
media.fire(TOUCH_COMPOSER_QUERY, true);
|
|
expect(probe.value()).toBe(false);
|
|
expect(media.listenerCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('isCoarsePointerDevice', () => {
|
|
it('detects the device truth regardless of the URL override', () => {
|
|
// Focus gating keys off the physical device: even when the user forces
|
|
// the CodeMirror path via ?composer=codemirror, programmatic focus must
|
|
// stay suppressed on touch devices.
|
|
installMatchMedia({ [TOUCH_COMPOSER_QUERY]: true });
|
|
setMaxTouchPoints(5);
|
|
window.history.replaceState({}, '', '/?composer=codemirror');
|
|
expect(isCoarsePointerDevice()).toBe(true);
|
|
});
|
|
|
|
it('returns false on fine-pointer devices', () => {
|
|
installMatchMedia({ [TOUCH_COMPOSER_QUERY]: false });
|
|
setMaxTouchPoints(0);
|
|
expect(isCoarsePointerDevice()).toBe(false);
|
|
});
|
|
});
|