qwen-code/packages/web-shell/client/components/ChatEditor.test.tsx
ComplexSimply 16f024338a
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 <noreply@anthropic.com>

* fix(web-shell): address review — textarea auto-grow, change notifications, caret restore

Address the five inline review suggestions on #7587:

- Auto-grow the mobile textarea with its content, capped by the computed
  CSS max-height (so --chat-editor-input-max-height overrides stay
  authoritative). Previously rows={1} plus resize:none meant multi-line
  drafts scrolled inside ~1.5 visible lines and the CSS max-height was
  dead. Asserted in the mobile e2e spec via bounding-box growth.
- Fire onInputTextChange from setMobileText, matching the CodeMirror
  updateListener contract: programmatic draft changes (setText, history
  restore, post-submit clear) now notify parent trackers too.
  handleMobileChange delegates to setMobileText.
- Restore the caret after mobile insertText: a controlled textarea resets
  the caret to the end on value change; setSelectionRange puts it back
  after React re-renders (rAF with a setTimeout fallback), matching the
  CodeMirror path's explicit selection anchor.
- Cover the mobile submitSearchMatch path: select a history match, submit
  through the shared pipeline, draft cleared.
- Cover the ChatEditor mobile quick-action gating: the history quick
  action opens the search UI (never dispatches into a missing EditorView)
  and the keyboard shortcut hints grid is hidden on the mobile composer
  with a desktop control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web-shell): decide the touch composer by media query alone

Independent macOS verification on #7587 found that Playwright's stock
WebKit iPhone profiles match '(hover: none) and (pointer: coarse)' but
report navigator.maxTouchPoints === 0, so the automatic detection selected
CodeMirror under unmodified WebKit emulation.

The maxTouchPoints requirement added nothing the AND media query does not
already provide: touch laptops are excluded by the query itself (their
primary pointer hovers and is fine), and the only devices that match the
query with zero touch points are emulated profiles and TV-style browsers,
where the plain textarea is a safe fallback. Dropping it makes stock
iPhone/WebKit Playwright runs exercise the automatic detection branch.

Real-device behavior is unchanged: phones and tablets match the query and
report touch points either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web-shell): keep the mobile textarea scrollable past its height cap

As .editorArea's last child the textarea inherited `overflow: clip` from
the `.editorArea > :last-child` wrapper rule (written for the CodeMirror
container, whose inner .cm-scroller does the scrolling). `clip` also
forbids programmatic scrolling, so once auto-grow reached the CSS
max-height, content beyond the cap was unreachable — scrollTop stayed
pinned at 0.

Override with `overflow-y: auto` via `.editorArea > textarea.mobileTextarea`
(the extra type selector outweighs the wrapper rule's specificity). New
mobile e2e regression fills 20 lines, asserts growth stops at the computed
300px cap, and verifies the overflow stays reachable: scrollHeight above
clientHeight and scrollTop actually moving to the bottom — the exact probe
from the review, which pinned at 0 before this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: ComplexSimply <rudy.arrowsong@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:14:25 +00:00

822 lines
26 KiB
TypeScript

// @vitest-environment jsdom
import { act, createRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
WebShellCustomizationProvider,
type ComposerTagClickHandler,
type ComposerTagRenderer,
type WebShellComposerTag,
type WebShellCustomization,
} from '../customization';
import { I18nProvider } from '../i18n';
import type {
MobileComposerBackend,
SlashMenuState,
} from '../hooks/useComposerCore';
import { ChatEditor, type ComposerToolbarAction } from './ChatEditor';
import { WebShellPortalRootContext } from '../portalRoot';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
Element.prototype.scrollIntoView = vi.fn();
const mockComposerCoreState = vi.hoisted(() => ({
composerTags: [] as WebShellComposerTag[],
removeTopTag: vi.fn(),
}));
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', {
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
vi.mock('../hooks/useComposerCore', async (importOriginal) => {
const React = await import('react');
const actual =
await importOriginal<typeof import('../hooks/useComposerCore')>();
return {
...actual,
useComposerCore: () => ({
containerRef: React.createRef<HTMLDivElement>(),
viewRef: { current: null },
mobileComposer: composerCoreState.mobileComposer,
focus: composerCoreState.focus,
submitText: vi.fn(),
clearText: vi.fn(),
getText: vi.fn(() => ''),
hasInput: vi.fn(() => false),
hasContent: false,
handle: {
focus: vi.fn(),
insertText: vi.fn(),
setText: vi.fn(),
clear: vi.fn(),
retryLast: vi.fn(),
addTags: vi.fn(),
removeInlineTags: vi.fn(),
submit: vi.fn(),
},
pastedImages: [],
removeImage: vi.fn(),
composerTags: mockComposerCoreState.composerTags,
removeTopTag: mockComposerCoreState.removeTopTag,
addTags: vi.fn(),
removeInlineTags: vi.fn(),
insertText: vi.fn(),
setText: vi.fn(),
submit: vi.fn(),
clear: vi.fn(),
retryLast: vi.fn(),
replaceEditorText: vi.fn(),
shellMode: false,
setShellMode: vi.fn(),
toggleShellMode: vi.fn(),
currentMode: 'default',
sessionName: undefined,
searchState: {
searchMode: false,
searchQuery: '',
searchMatches: [],
searchActiveIndex: 0,
searchInputRef: React.createRef<HTMLInputElement>(),
searchUiRef: React.createRef<HTMLDivElement>(),
openHistorySearch: composerCoreState.openHistorySearch,
closeSearch: vi.fn(),
submitSearchMatch: vi.fn(),
handleSearchKeyDown: vi.fn(),
handleSearchInput: vi.fn(),
handleSearchCompositionEnd: vi.fn(),
},
navigatePrevHistory: vi.fn(),
navigateNextHistory: vi.fn(),
showShortcutHints: false,
followupState: { isVisible: false, suggestion: '' },
disabled: false,
onAcceptFollowup: vi.fn(),
onDismissFollowup: vi.fn(),
slashMenu: composerCoreState.slashMenu,
closeSlashMenu: composerCoreState.closeSlashMenu,
selectSlashCompletion: vi.fn(),
acceptSlashCompletion: vi.fn(),
atMenu: null,
closeAtMenu: vi.fn(),
selectAtCompletion: vi.fn(),
acceptAtCompletion: vi.fn(),
enterAtCategory: vi.fn(),
backAtCategories: vi.fn(),
updateAtSearch: vi.fn(),
selectAtTab: vi.fn(),
}),
};
});
vi.mock('../voice/VoiceButton', () => ({
VoiceButton: () => <span data-testid="voice-button" />,
}));
const mounted: Array<{
root: Root;
container: HTMLDivElement;
portalRoot: HTMLDivElement;
}> = [];
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();
portalRoot.remove();
}
mockComposerCoreState.composerTags = [];
mockComposerCoreState.removeTopTag.mockReset();
});
function renderChatEditor(props: {
composerTags?: WebShellComposerTag[];
gitBranch?: string;
workspaceName?: string;
workspaceTitle?: string;
visibleToolbarActions?: readonly ComposerToolbarAction[];
renderComposerTagTooltip?: ComposerTagRenderer;
onComposerTagClick?: ComposerTagClickHandler;
currentMode?: string;
currentModel?: string;
availableModels?: Array<{ id: string; label?: string }>;
onSelectMode?: (mode: string) => void;
onSelectModel?: (model: string) => void;
customization?: WebShellCustomization;
}) {
const {
composerTags,
customization,
renderComposerTagTooltip,
onComposerTagClick,
...chatEditorProps
} = props;
if (composerTags) {
mockComposerCoreState.composerTags = composerTags;
}
const container = document.createElement('div');
container.dataset.webShellRoot = '';
const portalRoot = document.createElement('div');
portalRoot.dataset.webShellPortalRoot = '';
document.body.appendChild(container);
document.body.appendChild(portalRoot);
const root = createRoot(container);
mounted.push({ root, container, portalRoot });
act(() => {
root.render(
<WebShellPortalRootContext.Provider value={portalRoot}>
<WebShellCustomizationProvider
value={{
...customization,
renderComposerTagTooltip,
onComposerTagClick,
}}
>
<I18nProvider language="en">
<ChatEditor
onSubmit={() => undefined}
commands={[]}
showChatWidthToggle={false}
currentMode="default"
currentModel="qwen"
{...chatEditorProps}
/>
</I18nProvider>
</WebShellCustomizationProvider>
</WebShellPortalRootContext.Provider>,
);
});
return container;
}
describe('ChatEditor voice toolbar integration', () => {
it('mounts voice only when the host toolbar allows it', () => {
expect(
renderChatEditor({}).querySelector('[data-testid="voice-button"]'),
).not.toBeNull();
expect(
renderChatEditor({
visibleToolbarActions: ['voice'],
}).querySelector('[data-testid="voice-button"]'),
).not.toBeNull();
expect(
renderChatEditor({
visibleToolbarActions: [],
}).querySelector('[data-testid="voice-button"]'),
).toBeNull();
});
});
describe('ChatEditor composer tag icons', () => {
it('renders built-in icons for top composer tags', () => {
const kinds = ['extension', 'file', 'mcp', 'skill'] as const;
const container = renderChatEditor({
visibleToolbarActions: [],
composerTags: kinds.map((kind) => ({
id: `${kind}:reference`,
kind,
value: kind,
})),
});
expect(
container.querySelectorAll('[style*="--composer-tag-icon-url"]'),
).toHaveLength(kinds.length);
});
it('rejects unsafe custom icon URLs for top composer tags', () => {
const container = renderChatEditor({
visibleToolbarActions: [],
composerTags: [
{
id: 'custom:reference',
value: 'reference',
icon: 'javascript:alert(1)',
},
],
});
expect(container.innerHTML).not.toContain('javascript:alert');
expect(
container.querySelector('[style*="--composer-tag-icon-url"]'),
).toBeNull();
});
});
describe('ChatEditor git branch toolbar integration', () => {
it('shows the git branch indicator when the branch action is visible', () => {
const container = renderChatEditor({
gitBranch: 'feature/web-shell',
visibleToolbarActions: ['gitBranch'],
});
expect(
container.querySelector(
'[aria-label="Current Git branch: feature/web-shell"]',
),
).not.toBeNull();
});
it('hides the git branch indicator without a branch or visible action', () => {
expect(
renderChatEditor({
visibleToolbarActions: ['gitBranch'],
}).querySelector('[aria-label^="Current Git branch:"]'),
).toBeNull();
expect(
renderChatEditor({
gitBranch: 'main',
visibleToolbarActions: [],
}).querySelector('[aria-label^="Current Git branch:"]'),
).toBeNull();
});
});
describe('ChatEditor workspace toolbar integration', () => {
it('shows the workspace indicator when the workspace action is visible', () => {
const container = renderChatEditor({
workspaceName: 'api',
workspaceTitle: '/work/api',
visibleToolbarActions: ['workspace'],
});
const chip = container.querySelector('[aria-label="Workspace: api"]');
expect(chip).not.toBeNull();
// The full cwd is surfaced via the hover tooltip (mirroring the git branch
// chip), not a native `title` attribute.
expect(chip?.getAttribute('data-web-shell-workspace-title')).toBe(
'/work/api',
);
expect(chip?.getAttribute('title')).toBeNull();
expect(
container.querySelector('[data-web-shell-workspace]'),
).not.toBeNull();
});
it('falls back to the workspace name for the tooltip when no title is given', () => {
const container = renderChatEditor({
workspaceName: 'api',
visibleToolbarActions: ['workspace'],
});
// No `workspaceTitle` → the chip's tooltip uses the name itself.
expect(
container
.querySelector('[data-web-shell-workspace]')
?.getAttribute('data-web-shell-workspace-title'),
).toBe('api');
});
it('hides the workspace indicator without a name or visible action', () => {
expect(
renderChatEditor({
visibleToolbarActions: ['workspace'],
}).querySelector('[aria-label^="Workspace:"]'),
).toBeNull();
expect(
renderChatEditor({
workspaceName: 'api',
visibleToolbarActions: [],
}).querySelector('[aria-label^="Workspace:"]'),
).toBeNull();
});
it('renders the workspace chip before the git branch chip', () => {
const container = renderChatEditor({
gitBranch: 'main',
workspaceName: 'api',
workspaceTitle: '/work/api',
visibleToolbarActions: ['workspace', 'gitBranch'],
});
const ws = container.querySelector('[data-web-shell-workspace]');
const git = container.querySelector('[data-web-shell-git-branch]');
expect(ws).not.toBeNull();
expect(git).not.toBeNull();
// The workspace chip must precede the git-branch chip in document order.
expect(
ws!.compareDocumentPosition(git!) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
describe('ChatEditor top composer tag tooltip', () => {
it('activates the plain tag from click and keyboard with the outer tag rect', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders', removable: false },
];
const onComposerTagClick = vi.fn();
const container = renderChatEditor({
onComposerTagClick,
visibleToolbarActions: [],
});
const tag = container.querySelector<HTMLElement>(
'[data-web-shell-composer-tag]',
)!;
const trigger = tag.querySelector<HTMLElement>(
'[data-web-shell-composer-tag-trigger]',
)!;
const outerRect = { width: 200 } as DOMRect;
const innerRect = { width: 120 } as DOMRect;
tag.getBoundingClientRect = vi.fn(() => outerRect);
trigger.getBoundingClientRect = vi.fn(() => innerRect);
act(() => {
trigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true }),
);
});
expect(onComposerTagClick).toHaveBeenCalledTimes(3);
for (const [info] of onComposerTagClick.mock.calls) {
expect(info).toMatchObject({
tag: mockComposerCoreState.composerTags[0],
placement: 'composer',
readonly: false,
anchorRect: outerRect,
});
}
expect(container.querySelector('[role="tooltip"]')).toBeNull();
});
it('removes a tag without activating it', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders' },
];
const onComposerTagClick = vi.fn();
const container = renderChatEditor({
onComposerTagClick,
visibleToolbarActions: [],
});
const remove = container.querySelector<HTMLButtonElement>(
'[aria-label="Remove orders"]',
)!;
act(() => {
remove.dispatchEvent(new MouseEvent('click', { bubbles: true }));
remove.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true }),
);
remove.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Delete', bubbles: true }),
);
});
expect(mockComposerCoreState.removeTopTag).toHaveBeenCalledTimes(3);
expect(mockComposerCoreState.removeTopTag).toHaveBeenCalledWith('orders');
expect(onComposerTagClick).not.toHaveBeenCalled();
});
it('falls back to a plain tag when custom tooltip rendering throws', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders' },
];
const error = new Error('bad composer tooltip');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const container = renderChatEditor({
renderComposerTagTooltip: () => {
throw error;
},
visibleToolbarActions: [],
});
expect(container.textContent).toContain('Table');
expect(container.textContent).toContain('orders');
expect(container.querySelector('[role="tooltip"]')).toBeNull();
expect(warn).toHaveBeenCalledWith(
'[WebShell] composer tag tooltip render failed',
error,
);
warn.mockRestore();
});
it('opens custom content from a top tag in the configured portal root', () => {
mockComposerCoreState.composerTags = [
{ id: 'orders', label: 'Table', value: 'orders' },
];
const container = renderChatEditor({
renderComposerTagTooltip: () => 'Table details',
visibleToolbarActions: [],
});
const portalRoot = document.body.querySelector<HTMLElement>(
'[data-web-shell-portal-root]',
);
const tag = container.querySelector<HTMLElement>(
'[data-web-shell-composer-tag]',
);
const trigger = tag?.querySelector<HTMLElement>(
'[data-web-shell-composer-tag-trigger]',
);
const removeButton = tag?.querySelector<HTMLButtonElement>('button');
expect(trigger).not.toBeNull();
expect(trigger?.getAttribute('role')).toBeNull();
expect(trigger?.tabIndex).toBe(0);
expect(removeButton).not.toBeNull();
expect(trigger?.contains(removeButton ?? null)).toBe(false);
act(() => trigger?.focus());
const content = portalRoot?.querySelector<HTMLElement>(
'[data-web-shell-composer-tag-tooltip]',
);
const accessibleTooltip =
portalRoot?.querySelector<HTMLElement>('[role="tooltip"]');
expect(content).not.toBeNull();
expect(content?.textContent).toContain('Table details');
expect(container.contains(content ?? null)).toBe(false);
expect(portalRoot?.contains(content ?? null)).toBe(true);
expect(accessibleTooltip).not.toBeNull();
expect(trigger?.getAttribute('aria-describedby')).toBe(
accessibleTooltip?.id,
);
expect(tag?.hasAttribute('aria-describedby')).toBe(false);
});
});
describe('ChatEditor toolbar popovers', () => {
it('opens the approval mode popover and restores editor focus after selection', async () => {
const onSelectMode = vi.fn();
const container = renderChatEditor({
visibleToolbarActions: ['approvalMode'],
onSelectMode,
});
const focusTarget = document.createElement('input');
container.appendChild(focusTarget);
composerCoreState.focus.mockImplementation(() => focusTarget.focus());
act(() => {
container
.querySelector<HTMLButtonElement>('[data-web-shell-mode-button]')
?.click();
});
const popover = document.querySelector('[data-web-shell-toolbar-popover]');
expect(popover).not.toBeNull();
expect(popover?.getAttribute('data-side')).toBe('top');
const yolo = Array.from(popover?.querySelectorAll('button') ?? []).find(
(button) => button.textContent?.includes('(yolo)'),
);
await act(async () => {
yolo?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(onSelectMode).toHaveBeenCalledWith('yolo');
expect(document.activeElement).toBe(focusTarget);
expect(
document.querySelector('[data-web-shell-toolbar-popover]'),
).toBeNull();
});
it('observes custom toolbar render roots when measuring available width', () => {
const originalResizeObserver = globalThis.ResizeObserver;
const observed = new Set<Element>();
globalThis.ResizeObserver = class ResizeObserverMock {
constructor(_callback: ResizeObserverCallback) {}
observe(element: Element) {
observed.add(element);
}
unobserve() {}
disconnect() {}
};
try {
renderChatEditor({
visibleToolbarActions: [],
customization: {
renderComposerToolbarStart: () => (
<span data-test-toolbar-start>start</span>
),
renderComposerToolbarEnd: () => (
<span data-test-toolbar-end>end</span>
),
renderComposerToolbarRight: () => (
<span data-test-toolbar-right>right</span>
),
},
});
expect(
observed.has(document.querySelector('[data-test-toolbar-start]')!),
).toBe(true);
expect(
observed.has(document.querySelector('[data-test-toolbar-end]')!),
).toBe(true);
expect(
observed.has(document.querySelector('[data-test-toolbar-right]')!),
).toBe(true);
} finally {
globalThis.ResizeObserver = originalResizeObserver;
}
});
it('opens a searchable model popover and selects the filtered model', () => {
const onSelectModel = vi.fn();
const container = renderChatEditor({
visibleToolbarActions: ['model'],
availableModels: [
{ id: 'qwen-plus', label: 'Qwen Plus' },
{ id: 'qwen-max', label: 'Qwen Max' },
],
onSelectModel,
});
act(() => {
container
.querySelector<HTMLButtonElement>('[data-web-shell-model-button]')
?.click();
});
const search = document.querySelector<HTMLInputElement>(
'[data-web-shell-toolbar-popover] input[type="search"]',
);
expect(search).not.toBeNull();
expect(document.activeElement).toBe(search);
expect(search?.getAttribute('data-slot')).toBe('input');
act(() => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
valueSetter?.call(search, 'max');
search?.dispatchEvent(new Event('input', { bubbles: true }));
});
const options = Array.from(
document.querySelectorAll<HTMLButtonElement>(
'[data-web-shell-toolbar-popover] button',
),
);
expect(options.map((option) => option.textContent)).toEqual(['Qwen Max']);
act(() => options[0]?.click());
expect(onSelectModel).toHaveBeenCalledWith('qwen-max');
});
it('displays the model label instead of an opaque route id', () => {
const routeId = 'qwen-route:v1:abcdefghijklmnop';
const container = renderChatEditor({
visibleToolbarActions: ['model'],
currentModel: routeId,
availableModels: [{ id: routeId, label: 'Provider One' }],
});
const button = container.querySelector<HTMLButtonElement>(
'[data-web-shell-model-button]',
);
expect(button?.textContent).toContain('Provider One');
expect(button?.textContent).not.toContain(routeId);
});
it('switches between sibling toolbar popovers without dismissing the target', async () => {
const container = renderChatEditor({
visibleToolbarActions: ['approvalMode', 'model'],
currentModel: 'qwen-plus',
availableModels: [{ id: 'qwen-plus', label: 'Qwen Plus' }],
});
const modeButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-mode-button]',
);
const modelButton = container.querySelector<HTMLButtonElement>(
'[data-web-shell-model-button]',
);
act(() => modeButton?.click());
expect(modeButton?.getAttribute('aria-expanded')).toBe('true');
await act(async () => {
modelButton?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(modelButton?.getAttribute('aria-expanded')).toBe('true');
expect(
document.querySelector(
'[data-web-shell-toolbar-popover] input[type="search"]',
),
).not.toBeNull();
await act(async () => {
modeButton?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(modeButton?.getAttribute('aria-expanded')).toBe('true');
expect(
document.querySelector(
'[data-web-shell-toolbar-popover] input[type="search"]',
),
).toBeNull();
});
});
describe('ChatEditor slash command popovers', () => {
it('uses shadcn popovers for the command panel and hover detail', () => {
composerCoreState.slashMenu = {
kind: 'command',
from: 0,
to: 1,
query: '',
selectedIndex: 0,
items: [
{
id: 'help',
label: '/help',
apply: '/help',
detail: 'Show available commands',
section: 'Commands',
},
{
id: 'history-collapse',
label: '/history collapse-on-resume',
apply: '/history collapse-on-resume',
section: 'Commands',
},
],
};
renderChatEditor({ visibleToolbarActions: [] });
const panel = document.querySelector('[data-web-shell-slash-menu]');
expect(panel?.getAttribute('data-slot')).toBe('popover-content');
expect(
panel
?.querySelectorAll('[role="option"]')[1]
?.hasAttribute('data-has-description'),
).toBe(false);
const composingEscape = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
isComposing: true,
});
act(() => document.body.dispatchEvent(composingEscape));
expect(composingEscape.defaultPrevented).toBe(false);
expect(document.querySelector('[data-web-shell-slash-menu]')).toBe(panel);
const command = panel?.querySelector<HTMLButtonElement>('[role="option"]');
act(() => {
command?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
const detail = document.querySelector('[data-web-shell-slash-detail]');
expect(detail?.getAttribute('data-slot')).toBe('popover-content');
expect(detail?.textContent).toContain('Show available commands');
act(() => {
detail?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
});
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);
});
});
});