diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 387a510afee..1847d19fd69 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -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; diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index a7af5d47e64..484f39fda45 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -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(), 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(), searchUiRef: React.createRef(), - 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)[ + 'maxTouchPoints' + ]; + } + } + } + + function mobileComposerStub(): MobileComposerBackend { + return { + textareaRef: createRef(), + value: '', + onChange: vi.fn(), + onPaste: vi.fn(), + placeholder: '', + }; + } + + function openQuickActions(container: HTMLElement) { + const toggle = container.querySelector( + '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); + }); + }); +}); diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 6c5a9f27914..a3e457a1387 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -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({ ))} -
- {QUICK_KEY_ITEMS.map((item) => ( - - ))} -
+ {showKeyHints && ( +
+ {QUICK_KEY_ITEMS.map((item) => ( + + ))} +
+ )} ); @@ -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( ! )} -
+ {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. +