Skip to content

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

Merged
wenshao merged 4 commits into
QwenLM:mainfrom
ComplexSimply:fix/mobile-composer-5958
Jul 23, 2026
Merged

fix(web-shell): render a plain textarea composer on touch devices#7587
wenshao merged 4 commits into
QwenLM:mainfrom
ComplexSimply:fix/mobile-composer-5958

Conversation

@ComplexSimply

@ComplexSimply ComplexSimply commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

On touch devices the Web Shell composer now renders a plain controlled <textarea> instead of a CodeMirror EditorView. Detection is the (hover: none) and (pointer: coarse) media query (touch laptops keep the desktop editor — their primary pointer hovers and is fine; since 8eebb70 the query alone decides, so stock Playwright WebKit iPhone profiles exercise the automatic branch too), frozen at mount so a mid-session flip cannot drop the draft, with a ?composer=textarea|codemirror URL escape hatch for debugging and rollback.

The internal submit pipeline was hoisted out of the editor-creation effect and widened to view: EditorView | null, so both backends share the exact same submission path: input history, prompt building, top tags, pasted images, and slash/! interpretation from the submitted text all work unchanged on mobile. Enter inserts a newline natively (mobile chat convention) and submission goes through the existing Send button, which already tracks hasContent.

Independently of the backend switch, the non-gesture programmatic view.focus() calls (mount, disabled→enabled, dialog close, composerInput seed) are now suppressed on coarse-pointer devices — including when CodeMirror is forced via the escape hatch.

Why it's needed

On iOS Safari and Android Chrome the composer was unusable (#5958): tapping the input often did not open the virtual keyboard, and typed characters could be dropped. Two causes stack: mobile virtual keyboards and IMEs interact poorly with contenteditable-based editors in general, and the composer performs programmatic view.focus() outside user gestures — on iOS this claims document.activeElement without opening the keyboard, after which a later tap may no longer fire a fresh focus event, so the keyboard never appears. A native textarea gets the platform's full input stack (keyboard, IME, paste) for free. This closes the last major gap in the ongoing web-shell mobile work (#6000 mobile sidebar, #6584 mobile welcome slots, #5948 responsive TodoPanel).

Scale note: this lands larger than the triage bot's 50–80 line estimate (~300 product lines) because it reuses the single submit pipeline through a nullable view instead of building a parallel mobile composer — more diff inside useComposerCore, but no state-drift risk between two submission paths.

Known degradations on the textarea backend, all deliberate and all with working text-based equivalents: no slash/@ completion menus (typed commands still execute — verified in e2e), no inline tag chips (they fall back to the top placement), no history arrow navigation, no large-paste placeholders, no followup Tab-accept. The keyboard-shortcut hints grid is hidden on touch devices; the history-search quick action maps to the search UI, which is a plain React input and fully works.

Reviewer Test Plan

How to verify

  • cd packages/web-shell && npx vitest run client/hooks/useIsTouchComposer.test.tsx client/hooks/useComposerCore.mobile.dom.test.tsx client/hooks/useComposerCore.dom.test.tsx client/hooks/useComposerCore.test.ts — 60 tests: touch detection (AND media query, touch-laptop exclusion, both escape hatches, frozen-at-mount), the textarea backend (render seam with .cm-editor absence, typing→hasContent, pipeline reuse incl. !-prefix passthrough, inline→top tag fallback, imperative method mapping, composerInput seeding and auto-submit, history search from the draft, image paste via the shared helper), focus gating with a desktop negative control, plus the untouched desktop composer suites.
  • npx playwright test --project=mobile-chromium — 5 e2e tests under Pixel 7 emulation: textarea renders and CodeMirror never mounts; tap → type → Send round-trips through the mock daemon and streams the SSE reply; Enter inserts a newline without submitting; /help typed as plain text opens the Help dialog (command interpretation from submitted text); ?composer=codemirror forces the CodeMirror path.
  • npx playwright test --project=chromium — desktop e2e untouched (21 passed locally).
  • Manual: qwen serve, open a session from a phone or DevTools device emulation, tap the composer → keyboard opens, type, Send. Append ?composer=codemirror to compare against the old path.

Evidence (Before & After)

Before (CodeMirror path, ?composer=codemirror) After (textarea backend, light) After (dark) After (submitted turn)
(attached in a comment below) (attached in a comment below) (attached in a comment below) (attached in a comment below)

Screenshots are from the Playwright Pixel 7 emulation against the repo's mock daemon.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux

Linux: full vitest suite + Playwright chromium desktop and Pixel 7 mobile emulation + package tsc --noEmit. macOS (Apple Silicon): independently verified — full vitest suite, the reviewer-plan selection, Playwright Pixel 7 mobile (5/5) and desktop Chromium (21/21), plus a WebKit iPhone probe of the textarea path (see the verification comment below). Browser emulation cannot exercise the real OS virtual-keyboard stack; the real iOS Simulator keyboard layer remains pending on the verifying host (CoreSimulator issue) and will be posted as follow-up evidence.

Environment (optional)

Playwright against npm run dev with the repo's page.route mock daemon; no real daemon needed.

Risk & Scope

  • Main risk or tradeoff: the submit-pipeline hoist — submitText moved from the editor-creation effect to the render scope with view: EditorView | null. It reads only refs and stable setters, the Enter keymap goes through the same submitTextRef, and the full desktop suites pass unchanged, so desktop behavior is preserved byte-for-byte except the focus gating (a no-op on fine-pointer devices).
  • Not validated / out of scope: real-device iOS/Android behavior (virtual keyboard nuances, vendor IMEs such as Gboard); ?composer=codemirror remains as an immediate per-user rollback. iOS keyboard viewport occlusion (visualViewport insets) is a known separate issue already acknowledged in index.html and not addressed here.
  • Breaking changes / migration notes: none. useComposerCore/ChatEditor are internal (not part of the package's public exports); no new i18n keys.

Linked Issues

Fixes #5958

中文说明

这个 PR 做了什么

在触屏设备上,Web Shell 输入框改为渲染一个受控的原生 <textarea>,不再创建 CodeMirror EditorView。检测条件为 (hover: none) and (pointer: coarse) 媒体查询(带触屏的笔记本主指针可悬停且精细,天然被排除;自 8eebb70 起仅由该查询决定,Playwright 原生 WebKit iPhone 档位也能走到自动检测分支),在挂载时冻结(会话中途切换会丢草稿),并提供 ?composer=textarea|codemirror URL 逃生口用于调试与回滚。

内部提交管道从编辑器创建 effect 中提升到渲染作用域,参数放宽为 view: EditorView | null,两条路径共享完全相同的提交链路:输入历史、prompt 组装、顶部标签、粘贴图片、以及按提交文本解释的斜杠命令与 ! 前缀在移动端全部原样可用。Enter 原生换行(移动端聊天惯例),提交走已有的 Send 按钮(由 hasContent 驱动启用)。

与后端切换相互独立的一点:非手势的程序化 view.focus()(挂载、禁用恢复、对话框关闭、composerInput 播种)在粗指针设备上一律抑制——包括通过逃生口强制使用 CodeMirror 时。

为什么需要

在 iOS Safari 与 Android Chrome 上输入框此前不可用(#5958):点击输入框经常不弹出虚拟键盘,输入的字符也可能丢失。两个原因叠加:移动端虚拟键盘与输入法本身就与 contenteditable 编辑器兼容性差;同时输入框存在手势之外的程序化 view.focus()——iOS 上这会占住 document.activeElement 却不弹键盘,之后用户点击可能不再触发新的 focus 事件,键盘永远不出现。原生 textarea 免费获得平台完整的输入栈(键盘、输入法、粘贴)。这补齐了 web-shell 移动端适配(#6000 移动侧边栏、#6584 移动欢迎位、#5948 响应式 TodoPanel)的最后一块主要缺口。

规模说明:本 PR 比 triage bot 预估的 50–80 行大(约 300 行产品代码),原因是通过可空 view 复用单一提交管道,而不是另建一条并行的移动提交路径——useComposerCore 内 diff 更多,但避免了双管道间的状态漂移风险。

textarea 路径的已知降级(均为有意取舍,且都有文本等价物):无斜杠/@ 补全菜单(直接输入命令照常执行,e2e 已验证)、无行内标签 chips(回退到顶部放置)、无历史上下箭头导航、无大段粘贴占位符、无 followup Tab 接受。触屏设备上隐藏键盘快捷键提示格;「搜索历史」快捷操作映射到搜索 UI(纯 React input,完整可用)。

审阅者验证方案

如何验证

  • cd packages/web-shell && npx vitest run client/hooks/useIsTouchComposer.test.tsx client/hooks/useComposerCore.mobile.dom.test.tsx client/hooks/useComposerCore.dom.test.tsx client/hooks/useComposerCore.test.ts — 60 个测试:触屏检测(AND 媒体查询、排除触屏笔记本、两个逃生口、挂载冻结)、textarea 后端(渲染缝与 .cm-editor 缺席、输入驱动 hasContent、管道复用含 ! 前缀透传、行内→顶部标签回退、命令式方法映射、composerInput 播种与自动提交、从草稿打开历史搜索、经共享 helper 的图片粘贴)、focus 门控及桌面负向控制,以及未改动的桌面套件。
  • npx playwright test --project=mobile-chromium — Pixel 7 仿真下 5 个 e2e:textarea 渲染且 CodeMirror 不挂载;tap → 输入 → Send 经 mock daemon 往返并流式回显 SSE 回复;Enter 只换行不提交;纯文本输入 /help 打开 Help 对话框(按提交文本解释命令);?composer=codemirror 强制走 CodeMirror 路径。
  • npx playwright test --project=chromium — 桌面 e2e 未受影响(本地 21 个全过)。
  • 手动:qwen serve,手机或 DevTools 设备仿真打开会话,点击输入框 → 键盘弹出,输入,发送。URL 加 ?composer=codemirror 可对比旧路径。

证据(前后对比)

见上方英文部分的截图表格(Playwright Pixel 7 仿真 + 仓库自带 mock daemon)。

已测平台

Linux:完整 vitest 套件 + Playwright chromium 桌面与 Pixel 7 移动仿真 + 包内 tsc --noEmit。macOS(Apple Silicon):已独立验证——完整 vitest 套件、审阅者验证选集、Playwright Pixel 7 移动(5/5)与桌面 Chromium(21/21),另有 WebKit iPhone 档位对 textarea 路径的探测(见下方验证评论)。浏览器仿真无法覆盖真实的系统虚拟键盘栈;真实 iOS Simulator 键盘层因验证机 CoreSimulator 故障暂缓,结果将作为后续证据补充。

风险与范围

  • 主要风险/取舍:提交管道提升——submitText 从编辑器创建 effect 移到渲染作用域并放宽为 view: EditorView | null。它只读取 ref 与稳定的 setter,Enter keymap 走同一个 submitTextRef,桌面全套测试原样通过,因此除 focus 门控(细指针设备上为空操作)外桌面行为逐字节保持不变。
  • 未验证/范围外:真机 iOS/Android 行为(虚拟键盘细节、厂商输入法如 Gboard);?composer=codemirror 可作为用户级即时回滚。iOS 键盘遮挡视口(visualViewport insets)是 index.html 中已承认的独立问题,本 PR 不处理。
  • 破坏性变更/迁移说明:无。useComposerCore/ChatEditor 为内部实现(不在包的公开导出中);无新增 i18n key。

关联 Issue

Fixes #5958

🤖 Generated with Claude Code

Mobile browsers could not type into the Web Shell composer (QwenLM#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 QwenLM#5958

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed bug with clear reproduction — issue #5958 documents that on iOS Safari and Android Chrome, tapping the CodeMirror-based composer doesn't reliably open the virtual keyboard and typed characters can be dropped. The issue includes step-by-step reproduction and is labeled type/bug + welcome-pr by maintainers.

Direction: aligned. This is part of the ongoing web-shell mobile effort (#6000 sidebar, #6584 welcome slots, #5948 TodoPanel), and the linked issue explicitly suggests "fallback to a native <textarea> on touch devices" — which is exactly what this PR does. CHANGELOG has no direct reference to a touch composer, but mobile/web compatibility fixes are a recurring theme in similar tools and the area is clearly relevant.

Size: not applicable — all changes are in packages/web-shell/, no core paths touched. Production logic: ~743 lines (ChatEditor.tsx 68, useComposerCore.ts 580, useIsTouchComposer.ts 63, CSS 23, playwright config 9); test/e2e: ~673 lines.

Approach: the scope feels right. Rather than building a parallel mobile composer, the PR hoists the existing submit pipeline to accept view: EditorView | null so both backends share the same submission path — more diff inside useComposerCore, but no state-drift risk between two pipelines. The known degradations (no slash/@ completion menus, no inline tag chips, no history arrows) are deliberate and documented, with typed commands still executing. The ?composer=textarea|codemirror escape hatch is a sensible rollback path. One question worth thinking about: the focus-suppression logic applies even when CodeMirror is forced via the escape hatch on a touch device — is that intentional, or should the escape hatch also restore the original focus behavior for debugging?

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的 bug,有明确复现——issue #5958 记录了在 iOS Safari 和 Android Chrome 上,点击基于 CodeMirror 的输入框不能可靠弹出虚拟键盘,且输入的字符可能丢失。issue 包含逐步复现步骤,并被维护者标记为 type/bug + welcome-pr

方向:对齐。这是 web-shell 移动端适配工作的一部分(#6000 侧边栏、#6584 欢迎位、#5948 TodoPanel),关联 issue 明确建议"在触屏设备上回退到原生 <textarea>"——这正是本 PR 所做的。CHANGELOG 没有直接关于触屏输入框的引用,但移动端/Web 兼容性修复是同类工具的常见主题,该方向明显相关。

规模:不适用——所有改动在 packages/web-shell/ 内,未触及核心路径。生产逻辑约 743 行;测试/e2e 约 673 行。

方案:范围合理。PR 没有另建一条并行的移动端输入框,而是将现有提交管道提升为接受 view: EditorView | null,使两条路径共享同一提交链路——useComposerCore 内 diff 更多,但避免了双管道间的状态漂移风险。已知降级(无斜杠/@ 补全菜单、无行内标签 chips、无历史箭头导航)均为有意取舍并有文档记录,直接输入命令照常执行。?composer=textarea|codemirror 逃生口是合理的回滚路径。一个值得思考的问题:focus 抑制逻辑在触屏设备上即使通过逃生口强制使用 CodeMirror 时也会生效——这是有意的,还是逃生口也应该恢复原始的 focus 行为以便调试?

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at 71684a6a54c5697e7d076f8238781f00f69a61de · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal vs. actual approach: before reading the diff, I'd have done essentially the same thing — a useIsTouchComposer hook with (hover: none) and (pointer: coarse) + maxTouchPoints > 0 frozen at mount, a conditional textarea render in ChatEditor, the submit pipeline hoisted to accept view: EditorView | null, a URL escape hatch, and focus suppression on touch. The PR matches this almost exactly, which is a good sign — the approach is the natural one for this problem.

What's done well:

The submit pipeline hoist is the key structural change and it's clean. submitComposerText moved from the editor-creation effect to the render scope, widened to view: EditorView | null, and reads only refs — so both backends share the exact same submission path without state-drift risk. The old code inside the effect now just delegates to submitTextRef.current(view, ...), which is a minimal diff for the desktop path.

The collectClipboardImages extraction deduplicates the paste handling between CodeMirror and the textarea backend — same logic, one place. The isCoarsePointerDevice() vs useIsTouchComposer() split is well-motivated: focus gating keys off the physical device (so ?composer=codemirror on a phone still suppresses programmatic focus), while backend selection respects the URL override.

The mobileText state + mobileTextRef mirror is the standard React pattern for callbacks that need synchronous reads. The handleMobileChange callback manually updates both (equivalent to setMobileText plus the onInputTextChange call) with a stable empty dependency array.

No critical blockers found. The implementation follows project conventions (ESM, no any, collocated tests, comments explain why not what). The 16px font-size comment in the CSS (iOS auto-zoom) and the focus-suppression comments are the kind of why comments that earn their place.

One observation (not a blocker): the submitComposerText function is ~80 lines in the render scope. It's the existing submit logic relocated, not new code, and it reads only refs so the render-scope placement is correct. Just noting it for the reviewer's awareness.

Test Results

Vitest (58 tests — touch detection, mobile textarea backend, desktop suites)

 RUN  v3.2.4 packages/web-shell/client

 ✓ hooks/useIsTouchComposer.test.tsx (9 tests) 27ms
 ✓ hooks/useComposerCore.test.ts (16 tests) 5ms
 ✓ hooks/useComposerCore.mobile.dom.test.tsx (15 tests) 200ms
 ✓ hooks/useComposerCore.dom.test.tsx (18 tests) 318ms

 Test Files  4 passed (4)
      Tests  58 passed (58)
   Duration  1.65s

Playwright mobile-chromium (Pixel 7 emulation — 5 e2e tests)

  5 passed (11.8s)

Tests: textarea renders instead of CodeMirror; tap → type → Send round-trips through mock daemon with SSE reply; Enter inserts newline without submitting; /help typed as plain text opens Help dialog; ?composer=codemirror forces the CodeMirror path.

Playwright desktop chromium (regression check)

  1 flaky (pre-existing: "grows long text to the responsive composer cap at 600px @smoke")
  20 passed (51.7s)

TypeScript typecheck

npx tsc --noEmit — clean, no errors
中文说明

代码审查

独立方案 vs. 实际方案: 在读 diff 之前,我的方案几乎完全一致——useIsTouchComposer hook 使用 (hover: none) and (pointer: coarse) + maxTouchPoints > 0 并在挂载时冻结、ChatEditor 中条件渲染 textarea、提交管道提升为接受 view: EditorView | null、URL 逃生口、以及触屏设备上的 focus 抑制。PR 与此几乎完全吻合——说明方案是解决此问题的自然路径。

做得好的地方:

提交管道的提升是关键的结构变更,做得很干净。submitComposerText 从编辑器创建 effect 移到渲染作用域,放宽为 view: EditorView | null,只读取 ref——两条路径共享完全相同的提交链路,没有状态漂移风险。effect 内的旧代码现在只是委托给 submitTextRef.current(view, ...),对桌面路径的 diff 极小。

collectClipboardImages 的提取去重了 CodeMirror 和 textarea 后端的粘贴处理——同一逻辑,一处维护。isCoarsePointerDevice()useIsTouchComposer() 的分离有充分动机:focus 门控基于物理设备(所以手机上 ?composer=codemirror 仍然抑制程序化 focus),而后端选择尊重 URL 覆盖。

mobileText state + mobileTextRef 镜像是 React 中回调需要同步读取的标准模式。handleMobileChange 回调手动更新两者(等价于 setMobileText 加上 onInputTextChange 调用),依赖数组为空且稳定。

未发现关键阻塞问题。 实现遵循项目规范(ESM、无 any、测试共置、注释解释为什么而非做什么)。CSS 中 16px 字号的注释(iOS 自动缩放)和 focus 抑制的注释是那种值得存在的为什么注释。

一个观察(非阻塞):submitComposerText 函数在渲染作用域中约 80 行。它是现有提交逻辑的重定位,不是新代码,且只读取 ref,所以渲染作用域的放置是正确的。仅供审阅者知悉。

测试结果

Vitest(58 个测试——触屏检测、移动 textarea 后端、桌面套件)

4 个文件全部通过,58 个测试全部通过。

Playwright mobile-chromium(Pixel 7 仿真——5 个 e2e 测试)

5 个全部通过。测试覆盖:textarea 渲染而非 CodeMirror;tap → 输入 → Send 经 mock daemon 往返并流式回显;Enter 换行不提交;纯文本 /help 打开 Help 对话框;?composer=codemirror 强制 CodeMirror 路径。

Playwright desktop chromium(回归检查)

20 个通过,1 个 flaky(已有的:"grows long text to the responsive composer cap at 600px @smoke")。

TypeScript 类型检查

tsc --noEmit 干净,无错误。

Qwen Code · qwen3.8-max-preview

Reviewed at 71684a6a54c5697e7d076f8238781f00f69a61de · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head e0b3014. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

ℹ️ No screenshot changed against the PR base — but this PR edits 2 render-shaping files:

  • packages/web-shell/client/components/ChatEditor.module.css
  • packages/web-shell/client/components/ChatEditor.tsx

Either the change has no visual effect (logic, plumbing, a state the scenarios never reach), or no scenario renders this UI — in which case the preview cannot see it, and an empty result is a coverage gap rather than a clean bill of health. To make it visible, add a scenario to packages/web-shell/client/e2e/visuals/screenshots.spec.ts that seeds whatever state the UI is gated on; it then appears here as a head-only (NEW) capture.

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — clean across every stage; would merge without hesitation.

This is what a well-executed fix looks like. The problem is real and well-documented (#5958 — composer unusable on mobile browsers, labeled welcome-pr by maintainers). The approach is the natural one: detect touch devices, swap CodeMirror for a native textarea, and reuse the existing submit pipeline through a nullable view instead of building a parallel mobile path. I arrived at essentially the same design independently before reading the diff, which is a strong signal the approach is right.

The implementation is straightforward and every change earns its place. The submit pipeline hoist is the key structural move and it's done cleanly — submitComposerText reads only refs, the desktop path delegates through submitTextRef.current, and the full desktop suites pass unchanged. The isCoarsePointerDevice() / useIsTouchComposer() split (physical device for focus gating, URL-aware for backend selection) is well-motivated and correctly implemented. Known degradations are deliberate, documented, and have working text-based equivalents. The ?composer=codemirror escape hatch is a sensible rollback path.

Tests are thorough: 58 vitest tests covering detection, the textarea backend, focus gating with desktop negative controls, and the untouched desktop suites; 5 Playwright e2e tests under Pixel 7 emulation exercising the full tap → type → Send → SSE round-trip; desktop e2e regression clean (20 passed, 1 pre-existing flaky). TypeScript typecheck clean.

If I had to maintain this in six months, I'd thank the author — clear comments explaining why (iOS focus behavior, 16px font-size requirement), a single submit pipeline instead of two diverging paths, and an escape hatch for debugging.

中文说明

置信度:5/5 —— 每个阶段都干净;毫不犹豫地合并。

这是一个执行良好的 fix 的样子。问题真实且有充分文档(#5958——输入框在移动浏览器上不可用,被维护者标记为 welcome-pr)。方案是自然的路径:检测触屏设备,将 CodeMirror 替换为原生 textarea,并通过可空 view 复用现有提交管道,而不是另建一条并行的移动端路径。我在读 diff 之前独立得出了几乎完全相同的设计,这是方案正确的强信号。

实现直接了当,每处改动都有其必要性。提交管道的提升是关键的结构变更,做得很干净——submitComposerText 只读取 ref,桌面路径通过 submitTextRef.current 委托,桌面全套测试原样通过。isCoarsePointerDevice() / useIsTouchComposer() 的分离(物理设备用于 focus 门控,URL 感知用于后端选择)动机充分且实现正确。已知降级均为有意取舍,有文档记录,且有可用的文本等价物。?composer=codemirror 逃生口是合理的回滚路径。

测试全面:58 个 vitest 测试覆盖检测、textarea 后端、focus 门控及桌面负向控制、以及未改动的桌面套件;5 个 Playwright e2e 测试在 Pixel 7 仿真下验证完整的 tap → 输入 → Send → SSE 往返;桌面 e2e 回归干净(20 通过,1 个已有 flaky)。TypeScript 类型检查干净。

如果六个月后我来维护这段代码,我会感谢作者——清晰的注释解释为什么(iOS focus 行为、16px 字号要求)、单一提交管道而非两条分叉路径、以及用于调试的逃生口。

Qwen Code · qwen3.8-max-preview

Reviewed at 71684a6a54c5697e7d076f8238781f00f69a61de · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅

@ComplexSimply

Copy link
Copy Markdown
Collaborator Author

Evidence — Playwright Pixel 7 emulation (Linux, repo mock daemon; per the Tested-on table, real virtual-keyboard verification on iOS Simulator / Android Emulator will follow as additional evidence):

Before — CodeMirror path (?composer=codemirror) After — textarea backend (light)
before-codemirror after-light
After — textarea backend (dark) After — submitted turn (tap → type → Send → SSE reply)
after-dark after-turn

The desktop CI visuals workflow should report no visual change for this PR — the CodeMirror path is untouched on fine-pointer devices, which doubles as a regression control.

中文

以上截图来自 Playwright Pixel 7 仿真 + 仓库自带 mock daemon。第一张为逃生口强制的 CodeMirror 旧路径(对照),后三张为 textarea 新后端(明/暗主题、完整提交往返)。桌面 CI 视觉流水线预期显示「无视觉变化」——细指针设备上 CodeMirror 路径未动,可作为回归对照。iOS Simulator / Android Emulator(真实虚拟键盘栈)的验证结果将作为后续证据补充。

@ComplexSimply

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 23, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed. Suggestions are inline. Not reviewed: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

— qwen3.7-max via Qwen Code /review

Comment on lines +867 to +868
min-height: var(--chat-editor-input-min-height, 44px);
max-height: var(--chat-editor-input-max-height, 300px);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] max-height: 300px is dead CSS — no mechanism grows the textarea between min-height (44px) and max-height. The textarea has rows={1}, resize: none, and no JavaScript auto-grow (scrollHeight/autoGrow absent from the entire diff). A mobile user typing a multi-line message sees only ~1.5 lines at a time with internal scrolling.

Consider adding an auto-grow effect in handleMobileChange or a useEffect watching mobileText:

const el = mobileTextareaRef.current;
if (el) {
  el.style.height = 'auto';
  el.style.height = Math.min(el.scrollHeight, 300) + 'px';
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ed85222. Added an auto-grow effect watching the draft: height = min(scrollHeight, computed max-height), so --chat-editor-input-max-height overrides stay authoritative and the CSS cap is no longer dead. The mobile e2e spec now asserts the textarea's bounding box grows across newlines.

中文:已在 ed85222 修复——新增随草稿变化的 auto-grow effect,上限取计算样式的 max-height(CSS 变量覆盖仍生效);移动 e2e 增加了多行输入后高度增长的断言。

Comment on lines +1301 to +1304
const setMobileText = useCallback((text: string) => {
mobileTextRef.current = text;
setMobileTextState(text);
}, []);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] setMobileText does not fire onInputTextChangeRef, unlike the CodeMirror equivalent where every document change triggers onInputTextChangeRef.current?.(text) via the updateListener. — Failure scenario: setMobileText is called from restoreSelectedHistoryMatch (history restore), commitAccepted (post-submit clear), setText, insertText, replaceEditorText, and clear. After a mobile history restore, the parent's composerTextRef.current retains stale text, so useNewSessionSuggestion operates on outdated input.

Suggested change
const setMobileText = useCallback((text: string) => {
mobileTextRef.current = text;
setMobileTextState(text);
}, []);
const setMobileText = useCallback((text: string) => {
mobileTextRef.current = text;
setMobileTextState(text);
onInputTextChangeRef.current?.(text);
}, []);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ed85222, exactly as suggested — setMobileText now fires onInputTextChangeRef, and handleMobileChange delegates to it so there is a single notification path. Added a unit test asserting both programmatic setText and the post-submit clear notify the parent.

中文:已按建议在 ed85222 修复——setMobileText 统一上报 onInputTextChangehandleMobileChange 委托之;新增单测覆盖程序化 setText 与提交后清空两条通知路径。

Comment on lines +2916 to +2918
const start = el ? el.selectionStart : current.length;
const end = el ? el.selectionEnd : current.length;
setMobileText(current.slice(0, start) + text + current.slice(end));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The mobile insertText path does not restore the cursor position after inserting text, so the caret jumps to the end of the textarea value. — Failure scenario: if insertText is called while the caret is in the middle of the text (e.g., a tag chip insertion at the cursor), the value is correct but the caret jumps to the end. The CodeMirror path explicitly sets selection: { anchor: selection.from + insert.length }; the mobile path has no counterpart.

Suggested change
const start = el ? el.selectionStart : current.length;
const end = el ? el.selectionEnd : current.length;
setMobileText(current.slice(0, start) + text + current.slice(end));
const start = el ? el.selectionStart : current.length;
const end = el ? el.selectionEnd : current.length;
const newCursorPos = start + text.length;
setMobileText(current.slice(0, start) + text + current.slice(end));
requestAnimationFrame(() => {
el?.setSelectionRange(newCursorPos, newCursorPos);
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ed85222 — the caret is restored via setSelectionRange after React re-renders the controlled value (rAF with a setTimeout fallback for non-visual environments), matching the CodeMirror path's explicit selection anchor. The methods test now asserts selectionStart === 6 after a mid-text insert.

中文:已在 ed85222 修复——受控 value 更新后用 setSelectionRange 恢复光标(rAF,无 rAF 环境回退 setTimeout),与 CM 路径的显式 selection anchor 对齐;方法映射测试新增中间插入后的光标断言。

Comment on lines +3365 to 3368
if (!view && !isTouchComposer) return;
closeSearch(false);
if (!shellModeRef.current) {
restoreSelectedHistoryMatch(match);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] submitSearchMatch on the mobile path (view is null, isTouchComposer is true) is never exercised by any test. — Failure scenario: a mobile user opens history search, selects a previous prompt, and restoreSelectedHistoryMatch's mobile branch has a bug — the textarea would not be populated with the selected match. No test would catch this.

Consider adding a test in useComposerCore.mobile.dom.test.tsx that opens history search, calls submitSearchMatch, and asserts mobileComposer.value equals the match.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in ed85222 — new mobile test: build history by submitting, open history search from a draft, submitSearchMatch a previous prompt, and assert it round-trips through the shared pipeline (onSubmit receives the match, search closes, draft cleared).

中文:已在 ed85222 补测——移动端历史搜索选中并提交:断言经共享管道提交、搜索关闭、草稿清空。

Comment on lines +1512 to +1520
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;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The dispatchComposerKey mobile branch that intercepts Ctrl+R to open history search is not tested at any level. — Failure scenario: if the core.mobileComposer check were accidentally inverted, mobile users pressing the history-search button would dispatch a Ctrl+R into a non-existent CodeMirror view.

Consider adding a DOM test that mounts the mobile composer, calls pressQuickKey with {ctrlKey: true, key: 'r'}, and asserts that history search opens.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in ed85222 — ChatEditor-level tests with the mocked core exposing mobileComposer: tapping the history quick action calls searchState.openHistorySearch (this fails if the core.mobileComposer gate were inverted), plus an assertion that the keyboard shortcut hints grid is hidden on mobile with a desktop control.

中文:已在 ed85222 补测——ChatEditor 层用 mock core 暴露 mobileComposer:点历史快捷操作断言调用 openHistorySearch(门控若写反即失败);另断言移动端隐藏快捷键提示格并带桌面对照。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No changes were made in response to this review round.

The two issue-level comments are informational only:

  • @qwen-code-ci-bot (web-shell visual preview): reports no screenshot delta against the PR base, which is expected — the PR adds a touch-device textarea path that desktop visual scenarios do not exercise. The bot's suggestion to add a mobile scenario to screenshots.spec.ts is noted but out of scope for this fix; the PR already includes a dedicated Playwright mobile spec (web-shell.composer.mobile.spec.ts).
  • @ComplexSimply (PR author): provides Playwright Pixel 7 emulation screenshots as testing evidence. No change requested.

There are no reviews, no inline comments, no failed checks, and no still-red checks to address.

中文说明

本轮评审未做任何代码变更。

两条 issue 级评论均为信息性内容:

  • @qwen-code-ci-bot(web-shell 视觉预览):报告与 PR 基准相比无截图差异,这在预期之内——本 PR 新增的触屏设备 textarea 路径不会被桌面视觉场景触发。该机器人建议向 screenshots.spec.ts 添加移动端场景,已记录但不在本次修复范围内;PR 已包含专用的 Playwright 移动端测试(web-shell.composer.mobile.spec.ts)。
  • @ComplexSimply(PR 作者):提供 Playwright Pixel 7 仿真截图作为测试证据,未请求任何变更。

没有需要处理的评审意见、行内评论、失败检查或持续失败的检查项。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@ComplexSimply

Copy link
Copy Markdown
Collaborator Author

Independent macOS verification

I checked this PR at 71684a6a54c5697e7d076f8238781f00f69a61de on an Apple Silicon Mac (macOS 26.5.2, Node 22.22.0, Xcode 26.6).

Passed

  • npm ci, including the repository workspace builds and bundle generation
  • Full packages/web-shell Vitest suite: 2090/2090 passed (128 files)
  • Reviewer-plan Vitest selection: 58/58 passed (4 files)
  • packages/web-shell TypeScript typecheck and ESLint
  • Prettier check for all 8 files changed by this PR
  • Playwright Pixel 7 / Chromium mobile suite: 5/5 passed
    • textarea rendered and CodeMirror was absent
    • tap/type/Send completed the mock-daemon + SSE round trip
    • Enter inserted a newline without submitting
    • /help typed as text opened the Help dialog
    • ?composer=codemirror forced the legacy path
  • Playwright desktop Chromium regression suite: 21/21 passed

WebKit check

I also ran an iPhone 14 / WebKit probe. Playwright WebKit matched (hover: none) and (pointer: coarse), but reported navigator.maxTouchPoints === 0, so the unmodified emulation selected CodeMirror. After supplying the missing emulation signal (maxTouchPoints=1) before page load, the textarea backend passed 2/2 WebKit tests covering rendering, typing, newline behavior, Send/SSE, and /help.

This looks like a Playwright WebKit touch-profile limitation rather than evidence of a product failure: the same Pixel 7 profile reports maxTouchPoints=1 in Chromium. It does mean a stock Playwright iPhone/WebKit run does not validate the automatic detection branch as currently written.

Remaining limitation

I installed the official iOS 26.5 Simulator runtime (23F77) and disabled hardware-keyboard passthrough, but could not boot CoreSimulator because this Mac's syspolicyd hit file-descriptor exhaustion. The system log repeatedly reported UNIX error 24 / Failed to generate SecStaticCode ... error: 100024, and spctl returned Too many open files; simctl was consequently suspended before execution. This is a host OS security-service failure rather than a PR test failure.

Therefore the real Mobile Safari virtual-keyboard opening, IME behavior, and ?composer=codemirror negative control remain not tested. The installed runtime is ready for that final check after the host security service is restarted or repaired.

Overall, the code-level, Chromium mobile, desktop regression, and WebKit textarea-path checks are green on macOS. The only missing evidence is the real iOS virtual-keyboard layer.

…ions, caret restore

Address the five inline review suggestions on QwenLM#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>

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding

[P1] Restore scrolling after the mobile textarea reaches its height cap

The textarea is the direct last child of .editorArea, so it inherits overflow: clip from .editorArea > :last-child. Once the new auto-grow logic reaches max-height (300px by default), longer content is clipped and cannot be scrolled into view.

I reproduced this in headless Chrome with the PR CSS: clientHeight=300, scrollHeight=768; after assigning scrollTop=1000, scrollTop remained 0.

Please override the textarea with overflow-y: auto (or otherwise remove the inherited clipping) and add a regression test that enters enough lines to exceed the cap and verifies the textarea can scroll. The current mobile E2E assertion only verifies that three lines make the textarea grow, so it does not exercise this boundary.

Independent macOS verification on QwenLM#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>
@ComplexSimply

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough independent macOS verification — everything code-level, Chromium mobile, and desktop regression is confirmed green there, and the WebKit probe surfaced a real detection gap.

Acted on the WebKit finding in 8eebb70: the automatic detection now keys off the (hover: none) and (pointer: coarse) media query alone. The maxTouchPoints > 0 requirement added nothing the AND query does not already provide — touch laptops are excluded because their primary pointer hovers and is fine — while it broke stock Playwright WebKit iPhone profiles (which match the query but report zero touch points). The only real devices matching the query without touch points are TV-style browsers, where the plain textarea is a safe fallback. Net effect: an unmodified devices['iPhone 14'] WebKit run now exercises the automatic branch, no emulation-signal injection needed; real phones and tablets behave identically to before. Detection tests updated accordingly (the former exclusion test now documents the fallback).

The remaining gap is unchanged and honestly held: the real iOS virtual-keyboard layer (Simulator with hardware-keyboard passthrough off) is pending on the verifying host due to the CoreSimulator/syspolicyd issue, and the Tested-on section reflects that.

中文

感谢细致的 macOS 独立验证——代码级、Chromium 移动与桌面回归全部确认绿色,WebKit 探测更是发现了一个真实的检测缺口。

已在 8eebb70 处理 WebKit 发现:自动检测改为仅由 (hover: none) and (pointer: coarse) 媒体查询决定。maxTouchPoints > 0 条件并未提供 AND 查询之外的任何保护——触屏笔记本被排除是因为其主指针可悬停且精细——反而破坏了 Playwright 原生 WebKit iPhone 档位(匹配查询但报告零触点)。现实中匹配查询却无触点的只有 TV 类浏览器,对它们纯 textarea 是无害回退。净效果:未修改的 devices['iPhone 14'] WebKit 运行现在可以直接走到自动检测分支,无需注入仿真信号;真机手机/平板行为完全不变。检测测试已相应更新(原排除用例改为记录该回退行为)。

其余缺口保持诚实呈现:真实 iOS 虚拟键盘层(关闭硬件键盘直连的 Simulator)因验证机 CoreSimulator/syspolicyd 故障暂缓,Tested-on 一节已如实说明。

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>
@ComplexSimply

Copy link
Copy Markdown
Collaborator Author

@ytahdn Thanks — confirmed and fixed in e0b3014.

Root cause, as you diagnosed: the textarea is .editorArea's last child, so it inherited overflow: clip from the .editorArea > :last-child wrapper rule. That rule was written for the CodeMirror container div, where the inner .cm-scroller does the actual scrolling one level deeper — on the textarea there is no inner scroller, and clip also forbids programmatic scrolling, hence scrollTop pinned at 0 once auto-grow hit the cap. A plain overflow-y: auto on .mobileTextarea would not have been enough: :last-child gives the wrapper rule (0,2,0) specificity over the class's (0,1,0), so the override is .editorArea > textarea.mobileTextarea { overflow-y: auto; } — the extra type selector makes it (0,2,1) and wins regardless of rule order.

Regression test added (keeps the textarea scrollable once content exceeds the height cap, mobile e2e): fills 20 lines, waits for growth, asserts the computed cap is 300px and the box stops there, then runs your exact probe — assigns scrollTop = 10000 and asserts scrollHeight > clientHeight, scrollTop > 0, and scrollTop landing at the bottom (>= scrollHeight − clientHeight − 2). Verified red on the previous commit (scrollTop received 0, matching your reproduction) and green after the CSS fix; the full mobile suite is 6/6 and the desktop Chromium suite passes unchanged.

中文

已确认并在 e0b3014 修复。

根因与您的诊断一致:textarea 是 .editorArea 的最后一个子元素,继承了 .editorArea > :last-child 包装规则的 overflow: clip。该规则本为 CodeMirror 容器 div 而写(滚动发生在更内层的 .cm-scroller);textarea 没有内层滚动器,且 clip 连程序化滚动也禁止,因此 auto-grow 触顶后 scrollTop 被钉在 0。仅在 .mobileTextarea 上加 overflow-y: auto 并不够——:last-child 让包装规则的 specificity 为 (0,2,0),高于类选择器的 (0,1,0),所以覆盖写为 .editorArea > textarea.mobileTextarea((0,2,1),与规则顺序无关地胜出)。

回归测试已加(移动 e2e):填 20 行、等待增长,断言计算样式上限 300px 且盒高停在该处,然后执行您的原始探针——赋 scrollTop = 10000,断言 scrollHeight > clientHeightscrollTop > 0 且落到底部。已验证在修复前该测试红(scrollTop 收到 0,与您的复现一致),修复后绿;移动套件 6/6,桌面 Chromium 套件不变通过。

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the incremental fixes through e0b30141.

The textarea now overrides the inherited overflow: clip with overflow-y: auto, and the new mobile E2E test covers content exceeding the 300px height cap. I also independently reproduced the fixed behavior in Chrome: clientHeight=300, scrollHeight=768, and scrollTop=468 after scrolling to the end.

The previous blocking finding is resolved. No new issues found in the incremental changes.

@gwinthis gwinthis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: APPROVE (C=0)

Summary

Well-architected fix (+1437/-207, 9 files in packages/web-shell/) that renders a plain textarea composer on touch devices instead of the CodeMirror-based rich editor. Reuses a single submit pipeline via view: EditorView | null rather than building a parallel mobile composer — eliminates state-drift risk.

Verification Report

✓ 63/63 tests passed (3.57s) — 5 test files
✓ tsc --noEmit (web-shell) — clean, no errors

Test breakdown: useIsTouchComposer.test.tsx (9), useComposerCore.test.ts (16), useComposerCore.mobile.dom.test.tsx (17), ChatEditor.test.tsx (21), pre-existing useComposerCore.dom.test.tsx (18).

Key Design Decisions

  1. Single-pipeline reuse: Mobile mobileText state mirrors into mobileTextRef for synchronous reads, matching the CodeMirror updateListener contract. No parallel submission path → no state drift.

  2. Touch detection split into two concerns:

    • useIsTouchComposer() — frozen at mount via useState lazy initializer. Combines ?composer=textarea|codemirror URL escape hatch with (hover: none) and (pointer: coarse) media query. Non-reactive (swapping mid-session would drop draft/tags/images).
    • isCoarsePointerDevice() — non-reactive module function, ignores URL override, used only for focus gating. Correctly suppresses view.focus() even when CodeMirror is forced via URL.
  3. Focus gating separated from editor choice: Even if a user forces CodeMirror on a phone, the physical device is still coarse-pointer, so the 4 view.focus() call sites stay suppressed. Addresses root cause of #5958 (iOS claims activeElement without opening keyboard).

  4. CSS follows Web Shell conventions: CSS Modules scoping, 16px font prevents iOS Safari auto-zoom, specificity override for overflow: clip inheritance.

Minor Suggestion (non-blocking)

Dead code in dispatchComposerKey: the Ctrl+R mobile branch in ChatEditor.tsx (~line 1512) is unreachable because showKeyHints={!core.mobileComposer} hides the quick-key grid on mobile. Harmless defensive code — could be cleaned up in a follow-up.

Architecture Insight

Single pipeline with pluggable backends: When adding a device-specific UI variant, reuse the submit/state pipeline and swap only the input backend. This avoids the state-drift risk of parallel paths. The key: mirror the alternative input's state into the same ref structure the pipeline reads, so submitComposerText, getText, hasInput, and openHistorySearch work unchanged.

中文说明

评审:APPROVE (C=0)

概要

架构良好的修复(+1437/-207,9 文件):在触屏设备上用纯 textarea 替代 CodeMirror 富文本编辑器。通过 view: EditorView | null 复用单一提交流水线,而非构建并行移动端 composer——消除状态漂移风险。

验证报告

  • 63/63 测试通过(3.57s)
  • typecheck 通过

架构洞察

单一流水线 + 可插拔后端: 添加设备特定 UI 变体时,复用提交/状态流水线,仅切换输入后端。将替代输入的状态镜像到流水线读取的同一 ref 结构中,使所有操作无需修改。

— qwen3.7-max via Qwen Code /review

gwinthis pushed a commit that referenced this pull request Jul 23, 2026

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed — no blockers. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +752 to +759
} finally {
if (originalMaxTouchPoints) {
Object.defineProperty(
Navigator.prototype,
'maxTouchPoints',
originalMaxTouchPoints,
);
} else {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The withTouchDevice cleanup restores the prototype descriptor but never deletes the instance property when originalMaxTouchPoints exists. Object.defineProperty(navigator, 'maxTouchPoints', { value: 5 }) on line 747 creates an own property on navigator that shadows the prototype. In the truthy branch, only the prototype is restored — the instance property persists with value 5, shadowing the restored prototype for any subsequent reader.

Currently no test is affected (this describe block is the last in the file, and vitest isolates test files), but adding delete before restoring the prototype would make the cleanup symmetric with the else branch and prevent a latent test-isolation bug.

Suggested change
} finally {
if (originalMaxTouchPoints) {
Object.defineProperty(
Navigator.prototype,
'maxTouchPoints',
originalMaxTouchPoints,
);
} else {
} finally {
delete (navigator as unknown as Record<string, unknown>)[
'maxTouchPoints'
];
if (originalMaxTouchPoints) {
Object.defineProperty(
Navigator.prototype,
'maxTouchPoints',
originalMaxTouchPoints,
);
}
}

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao added this pull request to the merge queue Jul 23, 2026
Merged via the queue into QwenLM:main with commit 16f0243 Jul 23, 2026
69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web Shell input editor (CodeMirror) not working on mobile browsers

6 participants