Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/web-shell/client/components/ChatEditor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +867 to +868

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 增加了多行输入后高度增长的断言。

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;
Expand Down
99 changes: 96 additions & 3 deletions packages/web-shell/client/components/ChatEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';

Expand All @@ -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', {
Expand All @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Comment on lines +752 to +759

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

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);
});
});
});
68 changes: 53 additions & 15 deletions packages/web-shell/client/components/ChatEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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>
);
Expand Down Expand Up @@ -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;
}
Comment on lines +1512 to +1520

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(门控若写反即失败);另断言移动端隐藏快捷键提示格并带桌面对照。

const view = core.viewRef.current;
if (!view) return;
view.focus();
Expand Down Expand Up @@ -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}>
Expand Down Expand Up @@ -2468,6 +2505,7 @@ export const ChatEditor = memo(
actions={quickActions}
onRun={runQuickAction}
onPressKey={pressQuickKey}
showKeyHints={!core.mobileComposer}
/>
)}
</div>
Expand Down
Loading
Loading