qwen-code/packages/web-shell/client/components/ChatEditor.test.tsx

1139 lines
35 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 type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk';
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[],
pastedImages: [] as Array<{ data: string; media_type: string }>,
removeTopTag: vi.fn(),
}));
// Mock useWorkspace so BranchPickerPopover can render without a real provider.
vi.mock('@qwen-code/webui/daemon-react-sdk', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/webui/daemon-react-sdk')>();
return {
...actual,
useWorkspace: () => ({
client: {
workspaceGitBranches: vi.fn().mockResolvedValue({
v: 1,
local: [],
remote: [],
tags: [],
recent: [],
head: 'main',
detached: false,
}),
workspaceGitCheckout: vi.fn().mockResolvedValue(undefined),
workspaceGitCreateBranch: vi.fn().mockResolvedValue(undefined),
workspaceGitPush: vi
.fn()
.mockResolvedValue({ success: true, output: '' }),
workspaceGitPull: vi
.fn()
.mockResolvedValue({ success: true, output: '' }),
workspaceByCwd: () => ({
workspaceGit: vi.fn().mockResolvedValue({
v: 2,
branch: 'main',
detached: false,
staged: 0,
unstaged: 0,
untracked: 0,
conflicted: 0,
hasUpstream: false,
ahead: 0,
behind: 0,
stashCount: 0,
operation: null,
computedAt: 0,
}),
workspaceGitBranches: vi.fn().mockResolvedValue({
v: 1,
local: [],
remote: [],
tags: [],
recent: [],
head: 'main',
detached: false,
}),
workspaceGitCheckout: vi.fn().mockResolvedValue(undefined),
workspaceGitCreateBranch: vi.fn().mockResolvedValue(undefined),
workspaceGitPush: vi
.fn()
.mockResolvedValue({ success: true, output: '' }),
workspaceGitPull: vi
.fn()
.mockResolvedValue({ success: true, output: '' }),
listWorkspaceSessions: vi.fn().mockResolvedValue([]),
}),
},
capabilities: { features: [] },
}),
};
});
const composerCoreState = vi.hoisted(() => ({
slashMenu: null as SlashMenuState | null,
focus: vi.fn(),
closeSlashMenu: vi.fn(),
mobileComposer: null as unknown,
openHistorySearch: vi.fn(),
shellMode: false,
}));
const voiceButtonState = vi.hoisted(() => ({
onActiveChange: undefined as ((active: boolean) => void) | undefined,
}));
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),
hasAttachments:
mockComposerCoreState.pastedImages.length > 0 ||
mockComposerCoreState.composerTags.length > 0,
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(),
hasAttachments: () =>
mockComposerCoreState.pastedImages.length > 0 ||
mockComposerCoreState.composerTags.length > 0,
},
pastedImages: mockComposerCoreState.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: composerCoreState.shellMode,
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: ({
onActiveChange,
}: {
onActiveChange?: (active: boolean) => void;
}) => {
voiceButtonState.onActiveChange = onActiveChange;
return <span data-testid="voice-button" />;
},
}));
vi.mock('./SpecularComposerEffect', () => ({
SpecularComposerEffect: () => (
<span data-web-shell-composer-specular aria-hidden="true" />
),
}));
const mounted: Array<{
root: Root;
container: HTMLDivElement;
portalRoot: HTMLDivElement;
}> = [];
afterEach(() => {
composerCoreState.slashMenu = null;
composerCoreState.shellMode = false;
composerCoreState.focus.mockReset();
composerCoreState.closeSlashMenu.mockReset();
composerCoreState.mobileComposer = null;
composerCoreState.openHistorySearch.mockReset();
voiceButtonState.onActiveChange = undefined;
for (const { root, container, portalRoot } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
portalRoot.remove();
}
mockComposerCoreState.composerTags = [];
mockComposerCoreState.pastedImages = [];
mockComposerCoreState.removeTopTag.mockReset();
vi.useRealTimers();
});
function renderChatEditor(props: {
composerTags?: WebShellComposerTag[];
pastedImages?: Array<{ data: string; media_type: string }>;
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;
onAttachmentsChange?: (hasAttachments: boolean) => void;
placeholderText?: string;
disabled?: boolean;
followupState?: UseDaemonFollowupSuggestionReturn['followupState'];
customization?: WebShellCustomization;
}) {
const {
composerTags,
pastedImages,
customization,
renderComposerTagTooltip,
onComposerTagClick,
...chatEditorProps
} = props;
if (composerTags) {
mockComposerCoreState.composerTags = composerTags;
}
if (pastedImages) {
mockComposerCoreState.pastedImages = pastedImages;
}
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 animation layers', () => {
it('mounts the inert specular layer without replacing composer controls', () => {
const container = renderChatEditor({});
expect(
container.querySelector('[data-web-shell-composer-specular]'),
).not.toBeNull();
expect(
container.querySelector('[data-web-shell-composer-editor]'),
).not.toBeNull();
expect(
container.querySelector('[data-web-shell-composer-submit]'),
).not.toBeNull();
});
it('keeps the typewriter visible through automatic focus', () => {
const container = renderChatEditor({});
const editor = container.querySelector<HTMLElement>(
'[data-web-shell-composer-editor]',
);
const outside = document.createElement('button');
container.appendChild(outside);
editor!.tabIndex = 0;
act(() => editor!.focus());
expect(container.querySelector('[data-typewriter-visible]')).not.toBeNull();
act(() => {
editor!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
});
expect(container.querySelector('[data-typewriter-visible]')).toBeNull();
act(() => outside.focus());
expect(container.querySelector('[data-typewriter-visible]')).not.toBeNull();
});
it('plays the typewriter twice and then keeps the completed text', () => {
vi.useFakeTimers();
const container = renderChatEditor({ placeholderText: 'abc' });
const typewriter = () =>
container.querySelector('[data-web-shell-composer-typewriter]');
expect(typewriter()?.textContent).toBe('_');
act(() => vi.advanceTimersByTime(3 * 45));
expect(typewriter()?.textContent).toBe('abc_');
act(() => vi.advanceTimersByTime(3000));
expect(typewriter()?.textContent).toBe('_');
act(() => vi.advanceTimersByTime(3 * 45));
expect(typewriter()?.textContent).toBe('abc_');
act(() => vi.advanceTimersByTime(3001));
expect(typewriter()?.textContent).toBe('abc_');
});
it('does not mount the typewriter for an empty placeholder', () => {
const container = renderChatEditor({ placeholderText: '' });
expect(
container.querySelector('[data-web-shell-composer-typewriter]'),
).toBeNull();
expect(container.querySelector('[data-typewriter-visible]')).toBeNull();
});
it('shows the full placeholder without a caret under prefers-reduced-motion', () => {
const originalMatchMedia = window.matchMedia;
window.matchMedia = vi.fn(
(query: string) =>
({
matches: query === '(prefers-reduced-motion: reduce)',
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as MediaQueryList,
);
try {
const container = renderChatEditor({ placeholderText: 'abc' });
const typewriter = container.querySelector(
'[data-web-shell-composer-typewriter]',
);
expect(typewriter?.textContent).toBe('abc');
} finally {
window.matchMedia = originalMatchMedia;
}
});
it('replays the typewriter sequence after the empty editor loses focus', () => {
vi.useFakeTimers();
const container = renderChatEditor({ placeholderText: 'abc' });
const editor = container.querySelector<HTMLElement>(
'[data-web-shell-composer-editor]',
);
const outside = document.createElement('button');
container.appendChild(outside);
editor!.tabIndex = 0;
const typewriter = () =>
container.querySelector('[data-web-shell-composer-typewriter]');
act(() => editor!.focus());
act(() => {
editor!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
});
expect(typewriter()).toBeNull();
act(() => outside.focus());
expect(typewriter()?.textContent).toBe('_');
act(() => vi.advanceTimersByTime(3 * 45));
expect(typewriter()?.textContent).toBe('abc_');
});
it('hides the typewriter when disabled, in shell mode, or during a followup', () => {
const typewriterOf = (container: HTMLElement) =>
container.querySelector('[data-web-shell-composer-typewriter]');
expect(
typewriterOf(renderChatEditor({ placeholderText: 'abc' })),
).not.toBeNull();
expect(
typewriterOf(
renderChatEditor({ placeholderText: 'abc', disabled: true }),
),
).toBeNull();
composerCoreState.shellMode = true;
expect(
typewriterOf(renderChatEditor({ placeholderText: 'abc' })),
).toBeNull();
composerCoreState.shellMode = false;
expect(
typewriterOf(
renderChatEditor({
placeholderText: 'abc',
followupState: { suggestion: 'next', isVisible: true, shownAt: 0 },
}),
),
).toBeNull();
});
});
describe('ChatEditor attachment reporting', () => {
it('reports whether the composer has tags or pasted images', () => {
const onEmptyAttachmentsChange = vi.fn();
renderChatEditor({
onAttachmentsChange: onEmptyAttachmentsChange,
});
expect(onEmptyAttachmentsChange).toHaveBeenLastCalledWith(false);
const onTaggedAttachmentsChange = vi.fn();
renderChatEditor({
composerTags: [
{
id: 'file:reference',
kind: 'file',
value: 'reference',
},
],
onAttachmentsChange: onTaggedAttachmentsChange,
});
expect(onTaggedAttachmentsChange).toHaveBeenLastCalledWith(true);
const onImageAttachmentsChange = vi.fn();
renderChatEditor({
pastedImages: [{ data: 'abc', media_type: 'image/png' }],
onAttachmentsChange: onImageAttachmentsChange,
});
expect(onImageAttachmentsChange).toHaveBeenLastCalledWith(true);
});
});
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);
});
});
it('hides other toolbar actions while mobile voice capture is active', () => {
withTouchDevice(() => {
const container = renderChatEditor({
currentModel: 'qwen-test',
availableModels: [{ id: 'qwen-test' }],
});
expect(
container.querySelector('[data-web-shell-toolbar-leading]'),
).toBeTruthy();
expect(
container.querySelector(
'button[aria-label="more actions"][data-hide-during-mobile-voice]',
),
).toBeTruthy();
expect(
container.querySelector('[data-web-shell-composer-submit]'),
).toBeTruthy();
act(() => {
voiceButtonState.onActiveChange?.(true);
});
expect(
container.querySelector('[data-mobile-voice-active="true"]'),
).toBeTruthy();
act(() => {
voiceButtonState.onActiveChange?.(false);
});
expect(
container.querySelector('[data-mobile-voice-active="true"]'),
).toBeFalsy();
});
});
});