fix(web-shell): render a plain textarea composer on touch devices (#7587)

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: ComplexSimply <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-23 13:14:25 +00:00
1 parent e7097d0ef6
commit 16f024338a
9 files changed
+1437 -207

No files matched your search

@@ -859,6 +859,37 @@
line-height: 1.6;
}
/* Touch-device textarea backend (#5958). Mirrors the CodeMirror look; the
16px font size is mandatory: iOS Safari auto-zooms the page when focusing
any input with a smaller font. */
.mobileTextarea {
width: 100%;
min-height: var(--chat-editor-input-min-height, 44px);
max-height: var(--chat-editor-input-max-height, 300px);
padding: 0;
border: none;
outline: none;
background: transparent;
resize: none;
color: var(--chat-editor-text-primary, #e0e0e0);
caret-color: var(--chat-editor-accent-color, #4a9eff);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: 16px;
line-height: 1.6;
}
.mobileTextarea::placeholder {
color: var(--chat-editor-text-dimmed, #666);
}
/* As .editorArea's last child the textarea would inherit `overflow: clip`
from the rule above, pinning scrollTop to 0 — content beyond the height
cap would become unreachable. The extra `textarea` type wins that
specificity. */
.editorArea > textarea.mobileTextarea {
overflow-y: auto;
}
.editorArea .cm-scroller {
scrollbar-color: var(--chat-editor-border-color) transparent;
scrollbar-width: thin;
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from 'react';
import { act, createRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
@@ -11,7 +11,10 @@ import {
type WebShellCustomization,
} from '../customization';
import { I18nProvider } from '../i18n';
import type { SlashMenuState } from '../hooks/useComposerCore';
import type {
MobileComposerBackend,
SlashMenuState,
} from '../hooks/useComposerCore';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { WebShellPortalRootContext } from '../portalRoot';
@@ -28,6 +31,8 @@ const composerCoreState = vi.hoisted(() => ({
slashMenu: null as SlashMenuState | null,
focus: vi.fn(),
closeSlashMenu: vi.fn(),
mobileComposer: null as unknown,
openHistorySearch: vi.fn(),
}));
Object.defineProperty(window, 'matchMedia', {
@@ -48,6 +53,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
useComposerCore: () => ({
containerRef: React.createRef<HTMLDivElement>(),
viewRef: { current: null },
mobileComposer: composerCoreState.mobileComposer,
focus: composerCoreState.focus,
submitText: vi.fn(),
clearText: vi.fn(),
@@ -88,7 +94,7 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => {
searchActiveIndex: 0,
searchInputRef: React.createRef<HTMLInputElement>(),
searchUiRef: React.createRef<HTMLDivElement>(),
openHistorySearch: vi.fn(),
openHistorySearch: composerCoreState.openHistorySearch,
closeSearch: vi.fn(),
submitSearchMatch: vi.fn(),
handleSearchKeyDown: vi.fn(),
@@ -132,6 +138,8 @@ afterEach(() => {
composerCoreState.slashMenu = null;
composerCoreState.focus.mockReset();
composerCoreState.closeSlashMenu.mockReset();
composerCoreState.mobileComposer = null;
composerCoreState.openHistorySearch.mockReset();
for (const { root, container, portalRoot } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
@@ -727,3 +735,88 @@ describe('ChatEditor slash command popovers', () => {
expect(composerCoreState.closeSlashMenu).not.toHaveBeenCalled();
});
});
describe('ChatEditor mobile composer quick actions', () => {
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(
Navigator.prototype,
'maxTouchPoints',
);
function withTouchDevice(run: () => void) {
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
try {
run();
} finally {
if (originalMaxTouchPoints) {
Object.defineProperty(
Navigator.prototype,
'maxTouchPoints',
originalMaxTouchPoints,
);
} else {
delete (navigator as unknown as Record<string, unknown>)[
'maxTouchPoints'
];
}
}
}
function mobileComposerStub(): MobileComposerBackend {
return {
textareaRef: createRef<HTMLTextAreaElement>(),
value: '',
onChange: vi.fn(),
onPaste: vi.fn(),
placeholder: '',
};
}
function openQuickActions(container: HTMLElement) {
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="more actions"]',
);
expect(toggle).not.toBeNull();
act(() => toggle!.click());
}
it('maps the history quick action to the search UI on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const container = renderChatEditor({});
openQuickActions(container);
const historyButton = Array.from(
container.querySelectorAll('button'),
).find((button) => button.textContent === 'Question history');
expect(historyButton).not.toBeUndefined();
act(() => historyButton!.click());
expect(composerCoreState.openHistorySearch).toHaveBeenCalledTimes(1);
});
});
it('hides the keyboard shortcut hints grid on the mobile composer', () => {
withTouchDevice(() => {
composerCoreState.mobileComposer = mobileComposerStub();
const mobileContainer = renderChatEditor({});
openQuickActions(mobileContainer);
expect(
Array.from(mobileContainer.querySelectorAll('button')).some(
(button) => button.textContent === 'Tab',
),
).toBe(false);
composerCoreState.mobileComposer = null;
const desktopContainer = renderChatEditor({});
openQuickActions(desktopContainer);
expect(
Array.from(desktopContainer.querySelectorAll('button')).some(
(button) => button.textContent === 'Tab',
),
).toBe(true);
});
});
});
@@ -1083,10 +1083,14 @@ function QuickActionsPanel({
actions,
onRun,
onPressKey,
showKeyHints = true,
}: {
actions: readonly QuickActionItem[];
onRun: (action: QuickActionItem) => void;
onPressKey: (item: QuickKeyItem) => void;
// The keyboard shortcut grid is pointless without a hardware keyboard, so
// the mobile textarea backend hides it.
showKeyHints?: boolean;
}) {
const { t } = useI18n();
@@ -1110,20 +1114,22 @@ function QuickActionsPanel({
</button>
))}
</div>
<div className={styles.quickKeysGrid}>
{QUICK_KEY_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={styles.quickKey}
title={t(item.descriptionKey)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onPressKey(item)}
>
<span className={styles.quickKeyLabel}>{item.label}</span>
</button>
))}
</div>
{showKeyHints && (
<div className={styles.quickKeysGrid}>
{QUICK_KEY_ITEMS.map((item) => (
<button
key={item.id}
type="button"
className={styles.quickKey}
title={t(item.descriptionKey)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => onPressKey(item)}
>
<span className={styles.quickKeyLabel}>{item.label}</span>
</button>
))}
</div>
)}
</div>
</div>
);
@@ -1503,6 +1509,15 @@ export const ChatEditor = memo(
);
const dispatchComposerKey = useCallback(
(event: QuickKeyItem['event']) => {
if (core.mobileComposer) {
// No CodeMirror to dispatch into. History search is the one key
// action with a non-keyboard equivalent; the rest are hidden on
// the textarea backend.
if (event.ctrlKey && event.key === 'r') {
core.searchState.openHistorySearch();
}
return;
}
const view = core.viewRef.current;
if (!view) return;
view.focus();
@@ -2013,7 +2028,29 @@ export const ChatEditor = memo(
!
</span>
)}
<div ref={core.containerRef} data-web-shell-composer-editor />
{core.mobileComposer ? (
// Touch devices get a plain textarea instead of CodeMirror:
// mobile virtual keyboards and IMEs interact poorly with the
// contenteditable editor (#5958). Enter inserts a newline
// natively; submission goes through the Send button.
<textarea
ref={core.mobileComposer.textareaRef}
className={styles.mobileTextarea}
value={core.mobileComposer.value}
onChange={core.mobileComposer.onChange}
onPaste={core.mobileComposer.onPaste}
placeholder={core.mobileComposer.placeholder}
disabled={core.disabled}
rows={1}
enterKeyHint="enter"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
data-web-shell-composer-editor
/>
) : (
<div ref={core.containerRef} data-web-shell-composer-editor />
)}
</div>
<div ref={toolbarRef} className={styles.toolbar}>
<div ref={toolbarLeadingRef} className={styles.toolbarLeading}>
@@ -2468,6 +2505,7 @@ export const ChatEditor = memo(
actions={quickActions}
onRun={runQuickAction}
onPressKey={pressQuickKey}
showKeyHints={!core.mobileComposer}
/>
)}
</div>
@@ -0,0 +1,230 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// Mobile composer backend (#5958). Runs under the `mobile-chromium` project
// (Pixel 7 emulation: touch, coarse pointer, no hover), where the composer
// must render the plain-textarea backend instead of CodeMirror.
import { expect, test, type Page, type TestInfo } from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
replayCompleteEvent,
turnCompleteEvent,
type DaemonRequestRecord,
type MockDaemonController,
type WebShellDaemonScenario,
} from './utils/mockDaemon';
const COMPOSER_TEXTAREA = 'textarea[data-web-shell-composer-editor]';
test('renders the textarea backend instead of CodeMirror on touch devices', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
await expect(page.locator(COMPOSER_TEXTAREA)).toBeVisible();
await expect(page.locator('.cm-editor')).toHaveCount(0);
});
test('tap, type, and Send submit through the shared prompt pipeline', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.tap();
await page.keyboard.type('Ping from mobile');
await expect(textarea).toHaveValue('Ping from mobile');
const send = page.locator('[data-web-shell-composer-submit]');
await expect(send).toBeEnabled();
await send.tap();
await expect.poll(() => daemon.promptRequests().length).toBe(1);
expectPromptBodyToContainText(
firstRequestBody(daemon.promptRequests()),
'Ping from mobile',
);
await expect(textarea).toHaveValue('');
await daemon.sse.split(assistantTextEvent('Pong from fake SSE', { id: 10 }));
await daemon.sendEvent(turnCompleteEvent('prompt-mobile', { id: 11 }));
await expect(page.locator('[data-web-shell-message-list]')).toContainText(
'Pong from fake SSE',
);
});
test('Enter inserts a newline and does not submit', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.tap();
await page.keyboard.type('line one');
const singleLineHeight = (await textarea.boundingBox())!.height;
await page.keyboard.press('Enter');
await page.keyboard.type('line two');
await page.keyboard.press('Enter');
await page.keyboard.type('line three');
await expect(textarea).toHaveValue('line one\nline two\nline three');
expect(daemon.promptRequests()).toHaveLength(0);
// The textarea auto-grows with its content instead of scrolling inside a
// single visible line.
await expect
.poll(async () => (await textarea.boundingBox())!.height)
.toBeGreaterThan(singleLineHeight);
});
test('keeps the textarea scrollable once content exceeds the height cap', async ({
page,
}, testInfo) => {
// Regression: the textarea is .editorArea's last child and used to inherit
// `overflow: clip`, which pinned scrollTop to 0 once auto-grow hit the
// CSS max-height — content beyond the cap became unreachable.
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.tap();
const lines = Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join(
'\n',
);
await textarea.fill(lines);
// Auto-grow stops at the cap…
await expect
.poll(async () => (await textarea.boundingBox())!.height)
.toBeGreaterThan(250);
const metrics = await textarea.evaluate((el) => {
el.scrollTop = 10_000;
return {
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
scrollTop: el.scrollTop,
maxHeight: getComputedStyle(el).maxHeight,
};
});
expect(metrics.maxHeight).toBe('300px');
expect((await textarea.boundingBox())!.height).toBeLessThanOrEqual(304);
// …and the overflowing content stays reachable by scrolling.
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
expect(metrics.scrollTop).toBeGreaterThan(0);
expect(metrics.scrollTop).toBeGreaterThanOrEqual(
metrics.scrollHeight - metrics.clientHeight - 2,
);
});
test('slash commands typed as text still execute as commands', async ({
page,
}, testInfo) => {
// The textarea backend has no slash completion menu, but commands are
// interpreted from the submitted text at the App layer, so typing them
// out still works.
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await gotoSession(page, scenario, daemon);
const textarea = page.locator(COMPOSER_TEXTAREA);
await textarea.tap();
await page.keyboard.type('/help');
await page.locator('[data-web-shell-composer-submit]').tap();
await expect(page.getByRole('dialog', { name: 'Help' })).toBeVisible();
expect(daemon.promptRequests()).toHaveLength(0);
await expect(textarea).toHaveValue('');
});
test('?composer=codemirror escape hatch forces the CodeMirror path', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
const daemon = await installScenario(page, scenario, testInfo);
await page.goto(
`/session/${encodeURIComponent(scenario.sessionId)}?composer=codemirror`,
);
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
await completeReplay(page, daemon, scenario.sessionId);
await expect(page.locator('.cm-editor')).toBeVisible();
await expect(page.locator(COMPOSER_TEXTAREA)).toHaveCount(0);
});
async function installScenario(
page: Page,
scenario: WebShellDaemonScenario,
testInfo: TestInfo,
): Promise<MockDaemonController> {
return installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
}
async function gotoSession(
page: Page,
scenario: WebShellDaemonScenario,
daemon: MockDaemonController,
): Promise<void> {
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
await completeReplay(page, daemon, scenario.sessionId);
}
async function completeReplay(
page: Page,
daemon: MockDaemonController,
sessionId?: string,
replayedCount = 0,
): Promise<void> {
const connection = await daemon.sse.waitForConnection(sessionId);
await daemon.sendEvent(
replayCompleteEvent({
sessionId: connection.sessionId,
replayedCount,
}),
);
await expect(page.getByText('Loading...')).toHaveCount(0);
}
function firstRequestBody(
requests: readonly DaemonRequestRecord[],
): Record<string, unknown> {
const request = requests[0];
if (!request) throw new Error('Expected a recorded daemon request.');
expect(typeof request.body).toBe('object');
expect(request.body).not.toBeNull();
return request.body as Record<string, unknown>;
}
function expectPromptBodyToContainText(
body: Record<string, unknown>,
text: string,
): void {
const prompt = body['prompt'];
expect(Array.isArray(prompt)).toBe(true);
const blocks = prompt as readonly unknown[];
expect(
blocks.some(
(block) =>
typeof block === 'object' &&
block !== null &&
(block as Record<string, unknown>)['type'] === 'text' &&
(block as Record<string, unknown>)['text'] === text,
),
).toBe(true);
}
@@ -0,0 +1,358 @@
// @vitest-environment jsdom
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import type React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../i18n';
import { WebShellPortalRootContext } from '../portalRoot';
import { useComposerCore, type UseComposerCoreReturn } from './useComposerCore';
import type { WebShellComposerInput } from '../customization';
import { TOUCH_COMPOSER_QUERY } from './useIsTouchComposer';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const originalMatchMedia = window.matchMedia;
const originalMaxTouchPoints = Object.getOwnPropertyDescriptor(
Navigator.prototype,
'maxTouchPoints',
);
let container: HTMLDivElement | null = null;
let root: Root | null = null;
let latest: UseComposerCoreReturn | null = null;
function mockTouchDevice() {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query === TOUCH_COMPOSER_QUERY,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => true,
})) as unknown as typeof window.matchMedia;
Object.defineProperty(navigator, 'maxTouchPoints', {
value: 5,
configurable: true,
});
}
function Harness({
composerInput,
onSubmit,
onInputTextChange,
}: {
composerInput?: WebShellComposerInput;
onSubmit: ReturnType<typeof vi.fn>;
onInputTextChange?: (text: string) => void;
}) {
const composer = useComposerCore({
onSubmit,
onInputTextChange,
commands: [],
editorTheme: {},
composerInput,
composerInputVersion: composerInput ? 1 : undefined,
});
latest = composer;
// Mirrors the ChatEditor render seam: the mobile backend renders a plain
// controlled textarea at the single mount point, desktop keeps the
// CodeMirror container div.
return composer.mobileComposer ? (
<textarea
ref={composer.mobileComposer.textareaRef}
value={composer.mobileComposer.value}
onChange={composer.mobileComposer.onChange}
onPaste={composer.mobileComposer.onPaste}
placeholder={composer.mobileComposer.placeholder}
data-web-shell-composer-editor
/>
) : (
<div ref={composer.containerRef} data-web-shell-composer-editor />
);
}
async function mount({
composerInput,
onSubmit = vi.fn(),
onInputTextChange,
}: {
composerInput?: WebShellComposerInput;
onSubmit?: ReturnType<typeof vi.fn>;
onInputTextChange?: (text: string) => void;
} = {}) {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root!.render(
<WebShellPortalRootContext.Provider value={null}>
<I18nProvider language="en">
<Harness
composerInput={composerInput}
onSubmit={onSubmit}
onInputTextChange={onInputTextChange}
/>
</I18nProvider>
</WebShellPortalRootContext.Provider>,
);
});
return { onSubmit };
}
function typeText(text: string) {
act(() => {
latest!.mobileComposer!.onChange({
target: { value: text },
} as React.ChangeEvent<HTMLTextAreaElement>);
});
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
localStorage.removeItem('qwen-web-shell-history');
localStorage.removeItem('qwen-web-shell-command-history');
document.getElementById('web-shell-tooltip-styles')?.remove();
root = null;
container = null;
latest = 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({}, '', '/');
});
describe('useComposerCore mobile textarea backend', () => {
it('activates on touch devices: textarea renders, CodeMirror never mounts', async () => {
mockTouchDevice();
await mount();
expect(latest!.mobileComposer).not.toBeNull();
expect(container!.querySelector('textarea')).not.toBeNull();
expect(document.querySelector('.cm-editor')).toBeNull();
});
it('stays off on non-touch devices: CodeMirror mounts, mobileComposer is null', async () => {
await mount();
expect(latest!.mobileComposer).toBeNull();
expect(container!.querySelector('textarea')).toBeNull();
expect(document.querySelector('.cm-editor')).not.toBeNull();
});
it('respects the ?composer=codemirror escape hatch on touch devices', async () => {
mockTouchDevice();
window.history.replaceState({}, '', '/?composer=codemirror');
await mount();
expect(latest!.mobileComposer).toBeNull();
expect(document.querySelector('.cm-editor')).not.toBeNull();
});
it('drives hasContent and onInputTextChange from typing', async () => {
mockTouchDevice();
const onInputTextChange = vi.fn();
await mount({ onInputTextChange });
expect(latest!.hasContent).toBe(false);
typeText('hello');
expect(latest!.mobileComposer!.value).toBe('hello');
expect(latest!.hasContent).toBe(true);
expect(onInputTextChange).toHaveBeenCalledWith('hello');
typeText('');
expect(latest!.hasContent).toBe(false);
});
it('submits through the shared pipeline and clears the draft', async () => {
mockTouchDevice();
const { onSubmit } = await mount();
typeText('hello world');
act(() => latest!.submitText());
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toBe('hello world');
expect(latest!.mobileComposer!.value).toBe('');
expect(latest!.hasContent).toBe(false);
});
it('passes shell-prefixed text through unchanged for App-level handling', async () => {
mockTouchDevice();
const { onSubmit } = await mount();
typeText('!ls');
act(() => latest!.submitText());
expect(onSubmit.mock.calls[0][0]).toBe('!ls');
});
it('includes top tags in the submission and clears them after commit', async () => {
mockTouchDevice();
const { onSubmit } = await mount();
act(() => latest!.addTags([{ id: 'orders', value: 'orders' }]));
expect(latest!.hasInput()).toBe(true);
act(() => latest!.submitText());
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(latest!.composerTags).toHaveLength(0);
});
it('falls back to the top placement for inline tags', async () => {
mockTouchDevice();
await mount();
act(() =>
latest!.addTags([{ id: 'file', value: 'a.ts' }], { placement: 'inline' }),
);
expect(latest!.composerTags.map((tag) => tag.id)).toContain('file');
});
it('maps text methods onto the textarea state', async () => {
mockTouchDevice();
await mount();
act(() => latest!.setText('abc'));
expect(latest!.mobileComposer!.value).toBe('abc');
expect(latest!.getText()).toBe('abc');
act(() => latest!.insertText('xyz', { mode: 'replace' }));
expect(latest!.mobileComposer!.value).toBe('xyz');
act(() => latest!.replaceEditorText('helloworld'));
const textarea = container!.querySelector('textarea')!;
textarea.selectionStart = 5;
textarea.selectionEnd = 5;
act(() => latest!.insertText(' '));
expect(latest!.mobileComposer!.value).toBe('hello world');
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(textarea.selectionStart).toBe(6);
expect(textarea.selectionEnd).toBe(6);
act(() => latest!.clear());
expect(latest!.mobileComposer!.value).toBe('');
expect(latest!.hasInput()).toBe(false);
});
it('seeds the draft from composerInput', async () => {
mockTouchDevice();
await mount({ composerInput: { text: 'seeded' } });
expect(latest!.mobileComposer!.value).toBe('seeded');
expect(latest!.hasContent).toBe(true);
});
it('auto-submits from composerInput and clears the draft', async () => {
mockTouchDevice();
const onSubmit = vi.fn();
await mount({
composerInput: { text: 'seeded go', submit: true },
onSubmit,
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 5));
});
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toBe('seeded go');
expect(latest!.mobileComposer!.value).toBe('');
});
it('opens history search with the current draft', async () => {
mockTouchDevice();
await mount();
typeText('first message');
act(() => latest!.submitText());
typeText('draft');
act(() => latest!.searchState.openHistorySearch());
expect(latest!.searchState.searchMode).toBe(true);
expect(latest!.searchState.searchMatches).toContain('first message');
});
it('submits a selected history-search match through the pipeline', async () => {
mockTouchDevice();
const { onSubmit } = await mount();
typeText('first message');
act(() => latest!.submitText());
onSubmit.mockClear();
act(() => latest!.searchState.openHistorySearch());
expect(latest!.searchState.searchMatches).toContain('first message');
act(() => latest!.searchState.submitSearchMatch('first message'));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit.mock.calls[0][0]).toBe('first message');
expect(latest!.searchState.searchMode).toBe(false);
expect(latest!.mobileComposer!.value).toBe('');
});
it('notifies onInputTextChange on programmatic draft changes', async () => {
// The CodeMirror updateListener fires for programmatic dispatches too;
// the textarea backend must match, or parent trackers go stale after
// setText / history restore / post-submit clear.
mockTouchDevice();
const onInputTextChange = vi.fn();
await mount({ onInputTextChange });
act(() => latest!.setText('seeded'));
expect(onInputTextChange).toHaveBeenLastCalledWith('seeded');
act(() => latest!.submitText());
expect(onInputTextChange).toHaveBeenLastCalledWith('');
});
it('suppresses programmatic mount focus on the CodeMirror path for touch devices', async () => {
// With ?composer=codemirror a touch device still gets CodeMirror, but the
// non-gesture mount focus must stay suppressed: on iOS it claims
// document.activeElement without opening the keyboard, and later taps may
// no longer fire a fresh focus event.
mockTouchDevice();
window.history.replaceState({}, '', '/?composer=codemirror');
await mount();
const content = document.querySelector('.cm-content');
expect(content).not.toBeNull();
expect(document.activeElement).not.toBe(content);
});
it('keeps the programmatic mount focus on desktop', async () => {
await mount();
const content = document.querySelector('.cm-content');
expect(content).not.toBeNull();
expect(document.activeElement).toBe(content);
});
it('collects pasted images and lets plain text paste natively', async () => {
mockTouchDevice();
await mount();
const preventDefault = vi.fn();
const imageItem = {
type: 'image/png',
getAsFile: () =>
new File([new Uint8Array([137, 80, 78, 71])], 'x.png', {
type: 'image/png',
}),
};
await act(async () => {
latest!.mobileComposer!.onPaste({
clipboardData: { items: [imageItem] },
preventDefault,
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(preventDefault).toHaveBeenCalled();
expect(latest!.pastedImages).toHaveLength(1);
expect(latest!.pastedImages[0].media_type).toBe('image/png');
const textPreventDefault = vi.fn();
act(() => {
latest!.mobileComposer!.onPaste({
clipboardData: {
items: [{ type: 'text/plain', getAsFile: () => null }],
},
preventDefault: textPreventDefault,
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>);
});
expect(textPreventDefault).not.toHaveBeenCalled();
});
});
+423 -189
View File
@@ -35,6 +35,10 @@ import {
type Completion,
} from '@codemirror/autocomplete';
import { minimalSetup } from 'codemirror';
import {
isCoarsePointerDevice,
useIsTouchComposer,
} from './useIsTouchComposer';
import type { CommandInfo } from '../adapters/types';
import type { PromptImage } from '../adapters/promptTypes';
import {
@@ -1148,9 +1152,55 @@ function handleMultilineHistoryBoundary(
return 'history';
}
/**
* Extracts pasteable images from a clipboard, shared by the CodeMirror paste
* handler and the mobile textarea backend. Returns whether any image was
* found; matched files are decoded asynchronously and reported via `addImage`.
*/
function collectClipboardImages(
items: DataTransferItemList,
addImage: (image: PromptImage) => void,
): boolean {
let hasImage = false;
for (const item of items) {
if (
item.type.startsWith('image/') &&
/^image\/(png|jpeg|gif|webp)$/i.test(item.type)
) {
hasImage = true;
const file = item.getAsFile();
if (!file) continue;
const mediaType = item.type;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
addImage({ data: base64, media_type: mediaType });
};
reader.readAsDataURL(file);
}
}
return hasImage;
}
/**
* Editor backend for touch devices: instead of a CodeMirror EditorView, the
* composer renders a plain controlled `<textarea>` wired to these fields.
* Mobile virtual keyboards and IMEs interact poorly with CodeMirror's
* contenteditable (see #5958), while a native textarea gets the platform's
* full input stack for free. Null on desktop.
*/
export interface MobileComposerBackend {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
value: string;
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
onPaste: (event: React.ClipboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
}
export interface UseComposerCoreReturn {
containerRef: React.RefObject<HTMLDivElement | null>;
viewRef: React.RefObject<EditorView | null>;
mobileComposer: MobileComposerBackend | null;
focus: () => void;
submitText: () => void;
clearText: () => void;
@@ -1240,11 +1290,27 @@ export function useComposerCore(
const portalRoot = useWebShellPortalRoot();
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
// Mobile textarea backend (#5958). When active, no EditorView is ever
// created: ChatEditor renders a plain controlled <textarea> instead, and
// the imperative methods below branch on `isTouchComposer`. The draft is
// mirrored into a ref so submit/getText read synchronously.
const isTouchComposer = useIsTouchComposer();
const mobileTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [mobileText, setMobileTextState] = useState('');
const mobileTextRef = useRef('');
const tooltipPortalRef = useRef<HTMLDivElement | null>(null);
const onSubmitRef = useRef(onSubmit);
onSubmitRef.current = onSubmit;
const onInputTextChangeRef = useRef(onInputTextChange);
onInputTextChangeRef.current = onInputTextChange;
// Mirrors the CodeMirror updateListener contract: every draft change —
// typing or programmatic (setText, clear, history restore, post-submit
// clear) — notifies onInputTextChange, so parent trackers never go stale.
const setMobileText = useCallback((text: string) => {
mobileTextRef.current = text;
setMobileTextState(text);
onInputTextChangeRef.current?.(text);
}, []);
const onCycleModeRef = useRef(onCycleMode);
onCycleModeRef.current = onCycleMode;
const onToggleShortcutsRef = useRef(onToggleShortcuts);
@@ -1521,16 +1587,23 @@ export function useComposerCore(
const restoreSelectedHistoryMatch = useCallback(
(text: string) => {
const view = viewRef.current;
if (!view) return;
if (!view) {
if (isTouchComposer) {
// Plain-text restore: inline tag chips are not recreated on the
// textarea backend.
setMobileText(text);
}
return;
}
restoreHistoryEntry(view, text);
},
[restoreHistoryEntry],
[isTouchComposer, restoreHistoryEntry, setMobileText],
);
const composerInputRef = useRef(composerInput);
composerInputRef.current = composerInput;
const submitTextRef = useRef<
(
view: EditorView,
view: EditorView | null,
textOverride?: string,
tagsOverride?: readonly WebShellComposerTag[],
suppressFollowupCompletion?: boolean,
@@ -1686,7 +1759,7 @@ export function useComposerCore(
// Update hasContent when tags or images change
useEffect(() => {
const view = viewRef.current;
const text = view?.state.doc.toString() ?? '';
const text = view ? view.state.doc.toString() : mobileText;
const followupCompletion = getFollowupCompletion(
text,
followupState?.isVisible ? followupState.suggestion : null,
@@ -1700,6 +1773,7 @@ export function useComposerCore(
}, [
composerTags,
pastedImages,
mobileText,
followupState?.isVisible,
followupState?.suggestion,
]);
@@ -1755,10 +1829,10 @@ export function useComposerCore(
const openHistorySearch = useCallback(() => {
if (disabledRef.current) return;
const view = viewRef.current;
if (!view) return;
if (!view && !isTouchComposer) return;
closeSlashMenu();
closeAtMenu();
const query = view.state.doc.toString();
const query = view ? view.state.doc.toString() : mobileTextRef.current;
searchDraftRef.current = query;
setSearchMode(true);
setSearchQuery('');
@@ -1769,7 +1843,7 @@ export function useComposerCore(
setSearchActiveIndex(0);
history.resetSearch();
setTimeout(() => searchInputRef.current?.focus(), 0);
}, [closeAtMenu, closeSlashMenu, getSearchMatches]);
}, [closeAtMenu, closeSlashMenu, getSearchMatches, isTouchComposer]);
const openHistorySearchRef = useRef(openHistorySearch);
openHistorySearchRef.current = openHistorySearch;
@@ -1828,6 +1902,148 @@ export function useComposerCore(
view.focus();
}, [restoreHistoryEntry, restorePromptHistoryDraftTags]);
const handleMobileChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setMobileText(event.target.value);
},
[setMobileText],
);
// Auto-grow the mobile textarea with its content, capped by the CSS
// max-height (the cap is read from the computed style so a deployment
// overriding --chat-editor-input-max-height stays authoritative). Without
// this the rows={1} textarea would show ~1.5 lines with inner scrolling.
useEffect(() => {
if (!isTouchComposer) return;
const el = mobileTextareaRef.current;
if (!el) return;
el.style.height = 'auto';
if (el.scrollHeight > 0) {
const cap = parseFloat(getComputedStyle(el).maxHeight);
const next =
Number.isFinite(cap) && cap > 0
? Math.min(el.scrollHeight, cap)
: el.scrollHeight;
el.style.height = `${next}px`;
}
}, [isTouchComposer, mobileText]);
const handleMobilePaste = useCallback(
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = event.clipboardData?.items;
if (!items) return;
const hasImage = collectClipboardImages(items, (image) =>
setPastedImages((prev) => [...prev, image]),
);
// Plain text falls through to the native paste. Large-paste
// placeholders are a CodeMirror-only affordance.
if (hasImage) {
event.preventDefault();
}
},
[],
);
// Lives in the render scope (not the editor-creation effect) so the mobile
// textarea backend, which never instantiates an EditorView, can reuse the
// exact same submit pipeline with `view === null`.
const submitComposerText = (
view: EditorView | null,
textOverride?: string,
tagsOverride?: readonly WebShellComposerTag[],
suppressFollowupCompletion = false,
) => {
const inlineTags =
tagsOverride === undefined && view
? getInlineComposerTagPlacements(view)
: [];
const editorText = view ? view.state.doc.toString() : mobileTextRef.current;
const followup = followupStateRef.current;
const followupCompletion =
textOverride === undefined &&
!suppressFollowupCompletion &&
inlineTags.length === 0 &&
followup?.isVisible
? getFollowupCompletion(editorText, followup.suggestion)
: null;
const sourceText = textOverride ?? followupCompletion ?? editorText;
const leadingTrimLength = sourceText.length - sourceText.trimStart().length;
const rawText = sourceText.trim();
const normalizedInlineTags =
textOverride === undefined && followupCompletion === null
? inlineTags
.map((placement) => ({
...placement,
start: placement.start - leadingTrimLength,
end: placement.end - leadingTrimLength,
}))
.filter((placement) => placement.end > 0)
.map((placement) => ({
...placement,
start: Math.max(0, placement.start),
}))
: [];
const tags = tagsOverride ?? composerTagsRef.current;
if (!rawText && tags.length === 0 && inlineTags.length === 0) return true;
const textWithInlineTags =
tagsOverride === undefined
? replaceInlineTagPlacements(rawText, normalizedInlineTags)
: rawText;
const text = expandLargePastePlaceholders(
pendingPastesRef.current,
textWithInlineTags,
);
const prompt = buildComposerPrompt(text, tags);
const images = pastedImagesRef.current;
const isShellMode = shellModeRef.current;
const promptText = isShellMode ? `!${prompt}` : prompt;
const inputAnnotations = createInputAnnotationsFromComposerTags(
promptText,
[...tags, ...normalizedInlineTags.map((placement) => placement.tag)],
);
let committed = false;
const commitAccepted = () => {
if (committed) return;
committed = true;
setSlashMenu(null);
if (followupCompletion) {
onAcceptFollowupRef.current?.('enter', { skipOnAccept: true });
}
onDismissFollowupRef.current?.();
pendingPastesRef.current.clear();
nextPasteIdRef.current = 1;
if (isShellMode) {
shellHistoryActionsRef.current.push(text);
shellHistoryActionsRef.current.reset();
} else {
historyActionsRef.current.push(text);
historyActionsRef.current.reset();
}
historyBrowseActiveRef.current = false;
clearPromptHistoryDraftTags();
setComposerTags([]);
setPastedImages([]);
if (view) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '' },
effects: clearInlineTagsEffect.of(),
});
} else {
setMobileText('');
}
};
const accepted = onSubmitRef.current(
promptText,
images.length > 0 ? [...images] : undefined,
commitAccepted,
inputAnnotations.length > 0 ? { inputAnnotations } : undefined,
);
if (accepted === false) return true;
commitAccepted();
return true;
};
submitTextRef.current = submitComposerText;
// ---- Create CodeMirror EditorView ----
useEffect(() => {
if (!containerRef.current) return;
@@ -1896,92 +2112,13 @@ export function useComposerCore(
textOverride?: string,
tagsOverride?: readonly WebShellComposerTag[],
suppressFollowupCompletion = false,
) => {
const inlineTags =
tagsOverride === undefined ? getInlineComposerTagPlacements(view) : [];
const editorText = view.state.doc.toString();
const followup = followupStateRef.current;
const followupCompletion =
textOverride === undefined &&
!suppressFollowupCompletion &&
inlineTags.length === 0 &&
followup?.isVisible
? getFollowupCompletion(editorText, followup.suggestion)
: null;
const sourceText = textOverride ?? followupCompletion ?? editorText;
const leadingTrimLength =
sourceText.length - sourceText.trimStart().length;
const rawText = sourceText.trim();
const normalizedInlineTags =
textOverride === undefined && followupCompletion === null
? inlineTags
.map((placement) => ({
...placement,
start: placement.start - leadingTrimLength,
end: placement.end - leadingTrimLength,
}))
.filter((placement) => placement.end > 0)
.map((placement) => ({
...placement,
start: Math.max(0, placement.start),
}))
: [];
const tags = tagsOverride ?? composerTagsRef.current;
if (!rawText && tags.length === 0 && inlineTags.length === 0) return true;
const textWithInlineTags =
tagsOverride === undefined
? replaceInlineTagPlacements(rawText, normalizedInlineTags)
: rawText;
const text = expandLargePastePlaceholders(
pendingPastesRef.current,
textWithInlineTags,
) =>
submitTextRef.current(
view,
textOverride,
tagsOverride,
suppressFollowupCompletion,
);
const prompt = buildComposerPrompt(text, tags);
const images = pastedImagesRef.current;
const isShellMode = shellModeRef.current;
const promptText = isShellMode ? `!${prompt}` : prompt;
const inputAnnotations = createInputAnnotationsFromComposerTags(
promptText,
[...tags, ...normalizedInlineTags.map((placement) => placement.tag)],
);
let committed = false;
const commitAccepted = () => {
if (committed) return;
committed = true;
setSlashMenu(null);
if (followupCompletion) {
onAcceptFollowupRef.current?.('enter', { skipOnAccept: true });
}
onDismissFollowupRef.current?.();
pendingPastesRef.current.clear();
nextPasteIdRef.current = 1;
if (isShellMode) {
shellHistoryActionsRef.current.push(text);
shellHistoryActionsRef.current.reset();
} else {
historyActionsRef.current.push(text);
historyActionsRef.current.reset();
}
historyBrowseActiveRef.current = false;
clearPromptHistoryDraftTags();
setComposerTags([]);
setPastedImages([]);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '' },
effects: clearInlineTagsEffect.of(),
});
};
const accepted = onSubmitRef.current(
promptText,
images.length > 0 ? [...images] : undefined,
commitAccepted,
inputAnnotations.length > 0 ? { inputAnnotations } : undefined,
);
if (accepted === false) return true;
commitAccepted();
return true;
};
submitTextRef.current = submitText;
const insertNewline = (view: EditorView) => {
view.dispatch(view.state.replaceSelection('\n'));
@@ -2493,27 +2630,9 @@ export function useComposerCore(
paste(event) {
const items = event.clipboardData?.items;
if (!items) return false;
let hasImage = false;
for (const item of items) {
if (
item.type.startsWith('image/') &&
/^image\/(png|jpeg|gif|webp)$/i.test(item.type)
) {
hasImage = true;
const file = item.getAsFile();
if (!file) continue;
const mediaType = item.type;
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
setPastedImages((prev) => [
...prev,
{ data: base64, media_type: mediaType },
]);
};
reader.readAsDataURL(file);
}
}
const hasImage = collectClipboardImages(items, (image) =>
setPastedImages((prev) => [...prev, image]),
);
if (hasImage) {
event.preventDefault();
return true;
@@ -2560,7 +2679,13 @@ export function useComposerCore(
});
viewRef.current = view;
view.focus();
// Programmatic (non-gesture) focus is suppressed on touch devices even
// when CodeMirror is forced via ?composer=codemirror: on iOS it claims
// document.activeElement without opening the keyboard, and later taps may
// then never fire the focus event that would (#5958).
if (!isCoarsePointerDevice()) {
view.focus();
}
// Initial check
const initialText = view.state.doc.toString().trim();
@@ -2598,37 +2723,33 @@ export function useComposerCore(
EditorView.editable.of(!disabled),
),
});
if (!disabled) {
if (!disabled && !isCoarsePointerDevice()) {
view.focus();
}
}, [disabled]);
// Computed in the render scope so the mobile textarea backend can share the
// exact placeholder the CodeMirror path shows.
const followupSuggestion =
!disabled && followupState?.isVisible && followupState.suggestion
? followupState.suggestion
: null;
const composerPlaceholder =
followupSuggestion ??
(shellMode ? t('editor.shellPlaceholder') : placeholderText);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const followupSuggestion =
!disabled && followupState?.isVisible && followupState.suggestion
? followupState.suggestion
: null;
const nextPlaceholder =
followupSuggestion ??
(shellMode ? t('editor.shellPlaceholder') : placeholderText);
view.dispatch({
effects: [
placeholderCompartment.reconfigure(placeholder(nextPlaceholder)),
placeholderCompartment.reconfigure(placeholder(composerPlaceholder)),
followupGhostCompartment.reconfigure(
createFollowupGhostExtension(followupSuggestion),
),
],
});
}, [
disabled,
placeholderText,
shellMode,
t,
followupState?.isVisible,
followupState?.suggestion,
]);
}, [composerPlaceholder, followupSuggestion]);
useEffect(() => {
const view = viewRef.current;
@@ -2673,7 +2794,7 @@ export function useComposerCore(
closeSlashMenu();
closeAtMenu();
view.contentDOM.blur();
} else {
} else if (!isCoarsePointerDevice()) {
view.focus();
}
}, [closeAtMenu, dialogOpen, closeSlashMenu]);
@@ -2794,11 +2915,44 @@ export function useComposerCore(
// ---- Imperative methods ----
const focus = useCallback(() => {
if (isTouchComposer) {
mobileTextareaRef.current?.focus();
return;
}
viewRef.current?.focus();
}, []);
}, [isTouchComposer]);
const insertText = useCallback(
(text: string, options?: WebShellComposerTextOptions) => {
if (isTouchComposer) {
if (text) {
if (options?.mode === 'replace') {
setMobileText(text);
} else {
// No slash/at menus on the textarea backend: '/' and '@' are
// inserted literally and interpreted from the submitted text.
const el = mobileTextareaRef.current;
const current = mobileTextRef.current;
const start = el ? el.selectionStart : current.length;
const end = el ? el.selectionEnd : current.length;
const caret = start + text.length;
setMobileText(current.slice(0, start) + text + current.slice(end));
// A controlled textarea resets the caret to the end when its
// value changes; put it back after React re-renders, matching
// the CodeMirror path's explicit selection anchor.
const restoreCaret = () => {
mobileTextareaRef.current?.setSelectionRange(caret, caret);
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(restoreCaret);
} else {
window.setTimeout(restoreCaret, 0);
}
}
}
mobileTextareaRef.current?.focus();
return;
}
const view = viewRef.current;
if (!view || !text) {
view?.focus();
@@ -2876,24 +3030,40 @@ export function useComposerCore(
}, 0);
}
},
[closeSlashMenu, refreshAtMenuForView, refreshSlashMenuForView],
[
closeSlashMenu,
isTouchComposer,
refreshAtMenuForView,
refreshSlashMenuForView,
setMobileText,
],
);
const getText = useCallback(() => {
if (isTouchComposer) return mobileTextRef.current;
return viewRef.current?.state.doc.toString() ?? '';
}, []);
}, [isTouchComposer]);
const setText = useCallback((text: string) => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
effects: clearInlineTagsEffect.of(),
selection: { anchor: text.length },
scrollIntoView: true,
});
view.focus();
}, []);
const setText = useCallback(
(text: string) => {
if (isTouchComposer) {
// Unlike the CodeMirror path, no focus: on touch devices a
// programmatic focus would pop the virtual keyboard unexpectedly.
setMobileText(text);
return;
}
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
effects: clearInlineTagsEffect.of(),
selection: { anchor: text.length },
scrollIntoView: true,
});
view.focus();
},
[isTouchComposer, setMobileText],
);
const removeInlineTags = useCallback(
(predicate?: (tag: WebShellComposerTag) => boolean) => {
@@ -2929,6 +3099,9 @@ export function useComposerCore(
effects: clearInlineTagsEffect.of(),
});
}
if (clearTextOpt && isTouchComposer) {
setMobileText('');
}
if (clearTextOpt) {
setPastedImages([]);
pendingPastesRef.current.clear();
@@ -2941,7 +3114,7 @@ export function useComposerCore(
}
}
},
[removeInlineTags],
[isTouchComposer, removeInlineTags, setMobileText],
);
const clearText = useCallback(() => {
@@ -2954,7 +3127,9 @@ export function useComposerCore(
tagOptions?: WebShellComposerTagOptions,
) => {
if (tags.length === 0) return;
if (tagOptions?.placement === 'inline') {
// The textarea backend has no inline tag chips; inline requests fall
// through to the top placement below.
if (tagOptions?.placement === 'inline' && !isTouchComposer) {
const view = viewRef.current;
if (!view) return;
const selection = view.state.selection.main;
@@ -2999,7 +3174,7 @@ export function useComposerCore(
return next;
});
},
[resolveComposerTagIcon],
[isTouchComposer, resolveComposerTagIcon],
);
const removeTopTag = useCallback(
@@ -3016,35 +3191,41 @@ export function useComposerCore(
);
const hasInput = useCallback(() => {
const text = isTouchComposer
? mobileTextRef.current
: (viewRef.current?.state.doc.toString() ?? '');
return (
(viewRef.current?.state.doc.toString().trim().length ?? 0) > 0 ||
text.trim().length > 0 ||
composerTagsRef.current.length > 0 ||
pastedImagesRef.current.length > 0
);
}, []);
}, [isTouchComposer]);
const submit = useCallback((input?: WebShellComposerInput) => {
const view = viewRef.current;
if (!view) return;
const inlineTags = getInlineComposerTags(view);
if (input?.tagPlacement === 'inline') {
submitTextRef.current(view, input.text ?? '', input.tags ?? inlineTags);
return;
}
if (
input?.text !== undefined &&
input.tags === undefined &&
inlineTags.length > 0
) {
submitTextRef.current(view, input.text, inlineTags);
return;
}
submitTextRef.current(
view,
input?.text,
input ? (input.tags ?? []) : undefined,
);
}, []);
const submit = useCallback(
(input?: WebShellComposerInput) => {
const view = viewRef.current;
if (!view && !isTouchComposer) return;
const inlineTags = view ? getInlineComposerTags(view) : [];
if (input?.tagPlacement === 'inline') {
submitTextRef.current(view, input.text ?? '', input.tags ?? inlineTags);
return;
}
if (
input?.text !== undefined &&
input.tags === undefined &&
inlineTags.length > 0
) {
submitTextRef.current(view, input.text, inlineTags);
return;
}
submitTextRef.current(
view,
input?.text,
input ? (input.tags ?? []) : undefined,
);
},
[isTouchComposer],
);
const retryLast = useCallback(() => {
const last = historyActionsRef.current.getLastEntry(
@@ -3056,15 +3237,22 @@ export function useComposerCore(
setPastedImages([]);
}, []);
const replaceEditorText = useCallback((text: string) => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
selection: { anchor: text.length },
scrollIntoView: true,
});
}, []);
const replaceEditorText = useCallback(
(text: string) => {
if (isTouchComposer) {
setMobileText(text);
return;
}
const view = viewRef.current;
if (!view) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: text },
selection: { anchor: text.length },
scrollIntoView: true,
});
},
[isTouchComposer, setMobileText],
);
// ---- composerInput sync ----
@@ -3072,12 +3260,35 @@ export function useComposerCore(
const input = composerInputRef.current;
if (!input) return;
const view = viewRef.current;
if (!view) return;
if (!view && !isTouchComposer) return;
const tagPlacement = input.tagPlacement ?? 'top';
if (input.tags !== undefined && tagPlacement === 'top') {
setComposerTags([...input.tags]);
}
if (!view) {
// Mobile textarea backend: inline tag chips are not supported, so
// inline tags fall back to the top placement and only the plain text
// is seeded. No programmatic focus — that would pop the virtual
// keyboard outside a user gesture.
if (input.tags !== undefined && tagPlacement === 'inline') {
setComposerTags([...input.tags]);
}
if (input.text !== undefined) {
setMobileText(input.text);
}
let submitTimer: number | null = null;
if (input.submit) {
submitTimer = window.setTimeout(() => {
submit(input);
}, 0);
}
return () => {
if (submitTimer !== null) {
window.clearTimeout(submitTimer);
}
};
}
if (input.text !== undefined || tagPlacement === 'inline') {
const inlineTags =
tagPlacement === 'inline' ? [...(input.tags ?? [])] : [];
@@ -3112,7 +3323,10 @@ export function useComposerCore(
} else {
view.dispatch({ effects: clearInlineTagsEffect.of() });
}
if (input.text !== undefined || input.submit) {
if (
(input.text !== undefined || input.submit) &&
!isCoarsePointerDevice()
) {
view.focus();
}
let submitTimer: number | null = null;
@@ -3128,7 +3342,13 @@ export function useComposerCore(
window.clearTimeout(submitTimer);
}
};
}, [composerInputVersion, resolveComposerTagIcon, submit]);
}, [
composerInputVersion,
isTouchComposer,
resolveComposerTagIcon,
setMobileText,
submit,
]);
// ---- Search state ----
@@ -3174,7 +3394,7 @@ export function useComposerCore(
const submitSearchMatch = useCallback(
(match: string) => {
const view = viewRef.current;
if (!view) return;
if (!view && !isTouchComposer) return;
closeSearch(false);
if (!shellModeRef.current) {
restoreSelectedHistoryMatch(match);
@@ -3198,7 +3418,12 @@ export function useComposerCore(
setPastedImages([]);
replaceEditorText('');
},
[closeSearch, replaceEditorText, restoreSelectedHistoryMatch],
[
closeSearch,
isTouchComposer,
replaceEditorText,
restoreSelectedHistoryMatch,
],
);
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -3306,12 +3531,21 @@ export function useComposerCore(
return {
containerRef,
viewRef,
mobileComposer: isTouchComposer
? {
textareaRef: mobileTextareaRef,
value: mobileText,
onChange: handleMobileChange,
onPaste: handleMobilePaste,
placeholder: composerPlaceholder,
}
: null,
focus,
submitText: useCallback(() => {
const view = viewRef.current;
if (!view) return;
if (!view && !isTouchComposer) return;
submitTextRef.current(view);
}, []),
}, [isTouchComposer]),
clearText,
getText,
hasInput,
@@ -0,0 +1,172 @@
// @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);
});
});
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
/**
* Matches devices whose primary interaction is a touchscreen with no hover —
* phones and tablets. The AND of both features deliberately excludes touch
* laptops (hover-capable trackpad + touchscreen), which keep the desktop
* CodeMirror editor.
*/
export const TOUCH_COMPOSER_QUERY = '(hover: none) and (pointer: coarse)';
/**
* Raw device detection, ignoring the URL override. Used to gate programmatic
* (non-gesture) `view.focus()` calls: on iOS, focusing an editable element
* outside a user gesture does not open the keyboard but does claim
* `document.activeElement`, after which user taps may no longer fire a fresh
* focus event — the keyboard never appears. That must stay suppressed even
* when the user forces the CodeMirror path via `?composer=codemirror`.
*
* The media query alone decides — deliberately no `navigator.maxTouchPoints`
* requirement: Playwright's stock WebKit iPhone profiles (and some TV
* browsers) match the query while reporting zero touch points, and the
* textarea is a safe fallback wherever the primary pointer is coarse and
* cannot hover.
*/
export function isCoarsePointerDevice(): boolean {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(TOUCH_COMPOSER_QUERY).matches;
}
function resolveTouchComposer(): boolean {
if (typeof window !== 'undefined') {
// Escape hatch for debugging and rollback: ?composer=textarea forces the
// mobile backend, ?composer=codemirror forces the desktop editor.
const override = new URLSearchParams(window.location.search).get(
'composer',
);
if (override === 'textarea') return true;
if (override === 'codemirror') return false;
}
return isCoarsePointerDevice();
}
/**
* Decides once, at mount, whether the composer should use the plain
* `<textarea>` backend instead of CodeMirror. Intentionally NOT reactive:
* swapping editor backends mid-session would drop the draft, tags, and
* pasted images, so the choice is frozen for the lifetime of the component.
* Degrades to `false` (CodeMirror) when `matchMedia` is unavailable
* (SSR / the jsdom test default).
*/
export function useIsTouchComposer(): boolean {
const [isTouch] = useState<boolean>(resolveTouchComposer);
return isTouch;
}
+9
View File
@@ -36,6 +36,15 @@ export default defineConfig({
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testIgnore: ['**/visuals/**', '**/*.mobile.spec.ts'],
},
{
// Touch-device emulation for the mobile composer backend (#5958):
// coarse pointer + no hover + touch points, which flips the composer
// to the plain-textarea path.
name: 'mobile-chromium',
use: { ...devices['Pixel 7'] },
testMatch: '**/*.mobile.spec.ts',
},
],
});