diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index 1393a92cf83..4ee793f2ad5 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -14,7 +14,11 @@ border-radius: 12px; margin: 0; cursor: text; + --chat-editor-min-height: 140px; + --chat-editor-max-height: min(350px, 40vh); + --chat-editor-input-min-height: 44px; --chat-editor-input-max-height: 300px; + --chat-editor-attachments-max-height: 136px; --dac-glow-on: 0; --dac-glow-pulse: 0; --dac-g1: #6a78ff; @@ -27,8 +31,11 @@ position: relative; z-index: 2; display: flex; - height: 140px; + box-sizing: border-box; + min-height: var(--chat-editor-min-height, 140px); + max-height: var(--chat-editor-max-height, min(350px, 40vh)); flex-direction: column; + overflow: visible; border: 1.5px solid var(--agent-gray-200); border-radius: inherit; background: var(--chat-editor-bg-primary); @@ -709,8 +716,19 @@ } } +.attachments { + display: flex; + min-height: 0; + max-height: var(--chat-editor-attachments-max-height, 136px); + flex: 0 1 auto; + flex-direction: column; + overflow-y: auto; + overscroll-behavior: contain; +} + .tags { display: flex; + flex: 0 0 auto; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; @@ -731,14 +749,23 @@ line-height: 1.2; } +.tagContent { + display: inline-flex; + min-width: 0; + max-width: 100%; + flex: 1 1 auto; + align-items: center; +} + .tagTooltip { - position: absolute; z-index: calc(var(--web-shell-tooltip-z-index, 1000) + 1); - top: calc(100% + 6px); - left: 0; - display: none; + box-sizing: border-box; min-width: 160px; max-width: min(320px, 80vw); + max-height: min( + calc(100vh - 16px), + var(--radix-tooltip-content-available-height, calc(100vh - 16px)) + ); padding: 8px 10px; border: 1px solid var(--chat-editor-border-color); border-radius: 6px; @@ -748,14 +775,12 @@ font-family: var(--font-sans, system-ui, sans-serif); font-size: 12px; line-height: 1.5; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; white-space: normal; } -.tag:hover .tagTooltip, -.tag:focus-within .tagTooltip { - display: block; -} - .tagLabel, .tagValue { color: var(--chat-editor-text-primary); @@ -819,20 +844,34 @@ .editorArea { display: flex; flex: 1 1 auto; - align-items: flex-start; + align-items: stretch; gap: 6px; - min-height: 0; + min-height: var(--chat-editor-input-min-height, 44px); padding: 4px 0; - overflow: auto; + overflow: clip; } .editorArea > :last-child { + display: grid; min-width: 0; + min-height: var(--chat-editor-input-min-height, 44px); flex: 1; + overflow: clip; +} + +.editorArea :global(.cm-editor) { + height: 100%; + min-height: 0; +} + +.editorArea :global(.cm-scroller) { + height: 100%; + min-height: 0; } .shellPrefix { flex: 0 0 auto; + align-self: flex-start; padding-top: 1px; color: var(--chat-editor-accent-color); font-family: var(--font-mono); @@ -870,6 +909,7 @@ .toolbar { display: flex; + flex: 0 0 auto; align-items: center; justify-content: space-between; min-width: 0; @@ -1613,6 +1653,7 @@ .images { display: flex; + flex: 0 0 auto; gap: 6px; padding: 4px 0 0; flex-wrap: wrap; diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index 0113ebe482c..1f974eb9c6e 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -5,9 +5,21 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { I18nProvider } from '../i18n'; import { ChatEditor, type ComposerToolbarAction } from './ChatEditor'; +import type { + ComposerTagClickHandler, + ComposerTagRenderer, + WebShellComposerTag, +} from '../customization'; +import { WebShellCustomizationProvider } from '../customization'; +import { WebShellPortalRootContext } from '../portalRoot'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const mockComposerCoreState = vi.hoisted(() => ({ + composerTags: [] as WebShellComposerTag[], + removeTopTag: vi.fn(), +})); + Object.defineProperty(window, 'matchMedia', { writable: true, value: vi.fn().mockImplementation(() => ({ @@ -44,8 +56,8 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { }, pastedImages: [], removeImage: vi.fn(), - composerTags: [], - removeTopTag: vi.fn(), + composerTags: mockComposerCoreState.composerTags, + removeTopTag: mockComposerCoreState.removeTopTag, addTags: vi.fn(), removeInlineTags: vi.fn(), insertText: vi.fn(), @@ -96,13 +108,20 @@ vi.mock('../hooks/useComposerCore', async (importOriginal) => { }; }); -const mounted: Array<{ root: Root; container: HTMLDivElement }> = []; +const mounted: Array<{ + root: Root; + container: HTMLDivElement; + portalRoot: HTMLDivElement; +}> = []; afterEach(() => { - for (const { root, container } of mounted.splice(0)) { + for (const { root, container, portalRoot } of mounted.splice(0)) { act(() => root.unmount()); container.remove(); + portalRoot.remove(); } + mockComposerCoreState.composerTags = []; + mockComposerCoreState.removeTopTag.mockReset(); }); function renderChatEditor(props: { @@ -110,24 +129,37 @@ function renderChatEditor(props: { workspaceName?: string; workspaceTitle?: string; visibleToolbarActions?: readonly ComposerToolbarAction[]; + renderComposerTagTooltip?: ComposerTagRenderer; + onComposerTagClick?: ComposerTagClickHandler; }) { + const { renderComposerTagTooltip, onComposerTagClick, ...chatEditorProps } = + props; const container = document.createElement('div'); + const portalRoot = document.createElement('div'); + portalRoot.dataset.webShellPortalRoot = ''; document.body.appendChild(container); + document.body.appendChild(portalRoot); const root = createRoot(container); - mounted.push({ root, container }); + mounted.push({ root, container, portalRoot }); act(() => { root.render( - - undefined} - commands={[]} - showChatWidthToggle={false} - currentMode="default" - currentModel="qwen" - {...props} - /> - , + + + + undefined} + commands={[]} + showChatWidthToggle={false} + currentMode="default" + currentModel="qwen" + {...chatEditorProps} + /> + + + , ); }); @@ -222,3 +254,140 @@ describe('ChatEditor workspace toolbar integration', () => { ).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( + '[data-web-shell-composer-tag]', + )!; + const trigger = tag.querySelector( + '[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( + '[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( + '[data-web-shell-portal-root]', + ); + const tag = container.querySelector( + '[data-web-shell-composer-tag]', + ); + const trigger = tag?.querySelector( + '[data-web-shell-composer-tag-trigger]', + ); + const removeButton = tag?.querySelector('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( + '[data-web-shell-composer-tag-tooltip]', + ); + const accessibleTooltip = + portalRoot?.querySelector('[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); + }); +}); diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index bbe5126d4d3..e1d93b35a98 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -11,6 +11,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import type { CSSProperties, ReactNode, RefObject } from 'react'; +import { Tooltip as TooltipPrimitive } from 'radix-ui'; import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; import type { CommandInfo } from '../adapters/types'; import type { UseDaemonFollowupSuggestionReturn } from '@qwen-code/webui/daemon-react-sdk'; @@ -41,6 +42,7 @@ import { isSafeImageSrc } from './messages/Markdown'; import { ModeIcon } from './ModeIcon'; import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; +import { useWebShellPortalRoot } from '../portalRoot'; import { VoiceButton } from '../voice/VoiceButton'; import { GitBranchIndicator } from './GitBranchIndicator'; import { WorkspaceIndicator } from './WorkspaceIndicator'; @@ -203,6 +205,105 @@ const SLASH_PANEL_THEME_VARS = [ '--chat-editor-text-secondary', ] as const; +function TopComposerTag({ + tag, + content, + tooltip, + onActivate, + onRemove, +}: { + tag: WebShellComposerTag; + content: ReactNode; + tooltip: ReactNode | null | undefined; + onActivate?: (anchorRect: DOMRectReadOnly) => void; + onRemove?: () => void; +}) { + const anchorRef = useRef(null); + const portalRoot = useWebShellPortalRoot(); + const hasTooltip = tooltip !== undefined && tooltip !== null; + const tagContent = ( + { + if (!onActivate) return; + event.stopPropagation(); + onActivate( + anchorRef.current?.getBoundingClientRect() ?? + event.currentTarget.getBoundingClientRect(), + ); + }} + onKeyDown={(event) => { + if (!onActivate) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onActivate( + anchorRef.current?.getBoundingClientRect() ?? + event.currentTarget.getBoundingClientRect(), + ); + }} + > + {content} + + ); + const tagElement = ( + + {hasTooltip ? ( + + {tagContent} + + ) : ( + tagContent + )} + {onRemove && ( + + )} + + ); + + if (!hasTooltip) return tagElement; + + return ( + + {tagElement} + + + {tooltip} + + + + ); +} + function SendIcon() { return ( )}
- {core.composerTags.length > 0 && ( -
- {core.composerTags.map((tag) => { - const tagInfo = { - tag, - placement: 'composer' as const, - readonly: false, - }; - const tooltip = renderComposerTagTooltip?.(tagInfo); - return ( - { - if (!onComposerTagClick) return; - event.stopPropagation(); - onComposerTagClick({ - ...tagInfo, - anchorRect: - event.currentTarget.getBoundingClientRect(), - }); - }} - onKeyDown={(event) => { - if (!onComposerTagClick) return; - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - onComposerTagClick({ - ...tagInfo, - anchorRect: - event.currentTarget.getBoundingClientRect(), - }); - }} - > - {renderComposerTagContent(tag)} - {tag.removable !== false && ( - - )} - {tooltip !== undefined && tooltip !== null && ( - - {tooltip} - - )} - - ); - })} -
- )} - {core.pastedImages.length > 0 && ( -
- {core.pastedImages.map((img, i) => ( -
- - +
+ ))}
- ))} + )}
)} {core.slashMenu && ( diff --git a/packages/web-shell/client/e2e/composer-layout-harness.html b/packages/web-shell/client/e2e/composer-layout-harness.html new file mode 100644 index 00000000000..665cd39ac3b --- /dev/null +++ b/packages/web-shell/client/e2e/composer-layout-harness.html @@ -0,0 +1,12 @@ + + + + + + Composer layout harness + + +
+ + + diff --git a/packages/web-shell/client/e2e/composer-layout-harness.tsx b/packages/web-shell/client/e2e/composer-layout-harness.tsx new file mode 100644 index 00000000000..28cb8388f3c --- /dev/null +++ b/packages/web-shell/client/e2e/composer-layout-harness.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import '../styles/standalone.css'; + +const indexEntry = '../index.tsx'; +const { WebShellWithProviders } = await import(/* @vite-ignore */ indexEntry); + +const params = new URLSearchParams(window.location.search); +const sessionId = params.get('sessionId') ?? 'composer-layout-e2e'; +const tags = Array.from({ length: 18 }, (_, index) => ({ + id: `table-${index + 1}`, + label: 'Table', + value: `analytics_table_${index + 1}`, +})); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + `Details for ${tag.value}`} + /> + , +); diff --git a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts index 23481d846bb..732ed0a2d29 100644 --- a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts @@ -1,4 +1,10 @@ -import { expect, test, type Page, type TestInfo } from '@playwright/test'; +import { + expect, + test, + type Locator, + type Page, + type TestInfo, +} from '@playwright/test'; import { assistantTextEvent, createWebShellDaemonScenario, @@ -12,6 +18,8 @@ import { type WebShellDaemonScenario, } from './utils/mockDaemon'; +const COMPOSER_VIEWPORT_HEIGHTS = [1000, 800, 600] as const; + test('loads replayed transcript and connects to fake daemon @smoke', async ({ page, }, testInfo) => { @@ -197,6 +205,198 @@ test('opens slash menu, resume dialog, model dialog, and theme dialog @smoke', a await expect(page.locator('[data-web-shell-theme-dialog]')).toHaveCount(0); }); +for (const viewportHeight of COMPOSER_VIEWPORT_HEIGHTS) { + test(`grows long text to the responsive composer cap at ${viewportHeight}px @smoke`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1280, height: viewportHeight }); + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + + await gotoSession(page, scenario, daemon); + const surface = page.locator('[data-web-shell-composer-surface]'); + const initialHeight = await composerHeight(page); + expect(initialHeight).toBe(140); + + await replaceComposerText( + page, + Array.from( + { length: 10 }, + (_, index) => `Visible line ${index + 1}`, + ).join('\n'), + ); + await expect + .poll(() => composerHeight(page)) + .toBeGreaterThan(initialHeight); + + await replaceComposerText( + page, + Array.from({ length: 80 }, (_, index) => `Capped line ${index + 1}`).join( + '\n', + ), + ); + await expectCappedComposerLayout(page, viewportHeight); + await expect(surface).toBeVisible(); + + await page.keyboard.press('Control+r'); + const historySearch = surface.locator('input'); + await expect(historySearch).toBeVisible(); + const searchPanel = historySearch.locator('..').locator('..'); + await expect + .poll(async () => { + const [panelBox, surfaceBox] = await Promise.all([ + searchPanel.boundingBox(), + surface.boundingBox(), + ]); + if (!panelBox || !surfaceBox) return Number.POSITIVE_INFINITY; + return panelBox.y + panelBox.height - surfaceBox.y; + }) + .toBeLessThanOrEqual(-7); + await page.keyboard.press('Escape'); + await expect(historySearch).toHaveCount(0); + + const modeButton = page.locator('[data-web-shell-mode-button]'); + await modeButton.click(); + const modeDropdown = modeButton.locator('..').locator(':scope > div'); + await expect(modeDropdown).toBeVisible(); + await expect + .poll(async () => { + const [dropdownBox, buttonBox] = await Promise.all([ + modeDropdown.boundingBox(), + modeButton.boundingBox(), + ]); + if (!dropdownBox || !buttonBox) return Number.POSITIVE_INFINITY; + return dropdownBox.y + dropdownBox.height - buttonBox.y; + }) + .toBeLessThanOrEqual(-3); + await page.keyboard.press('Escape'); + + await replaceComposerText(page, 'Short draft'); + await expect.poll(() => composerHeight(page)).toBe(initialHeight); + }); +} + +for (const viewportHeight of COMPOSER_VIEWPORT_HEIGHTS) { + test(`bounds shared attachments and long text at ${viewportHeight}px @smoke`, async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1280, height: viewportHeight }); + const scenario = createWebShellDaemonScenario({ + sessionId: `composer-layout-${viewportHeight}`, + }); + const daemon = await installScenario(page, scenario, testInfo); + + await gotoComposerLayoutHarness(page, scenario, daemon); + const tags = page.locator('[data-web-shell-composer-tag]'); + await expect(tags).toHaveCount(18); + await expect(tags.first()).toBeVisible(); + + await pasteComposerImages(page, 8); + const images = page.locator( + '[data-web-shell-composer-attachments] img[src^="data:image/png;base64,"]', + ); + await expect(images).toHaveCount(8); + await expectImagesDecoded(images); + await replaceComposerText( + page, + Array.from( + { length: 80 }, + (_, index) => `Attachment line ${index + 1}`, + ).join('\n'), + ); + + await expectCappedComposerLayout(page, viewportHeight); + const attachments = page.locator('[data-web-shell-composer-attachments]'); + await expect(attachments).toBeVisible(); + await expect + .poll(async () => (await attachments.boundingBox())?.height ?? 0) + .toBeLessThanOrEqual(136); + await expect + .poll(() => + attachments.evaluate( + (element) => element.scrollHeight > element.clientHeight + 1, + ), + ) + .toBe(true); + + if (viewportHeight === 600) { + await tags + .first() + .locator('[data-web-shell-composer-tag-trigger]') + .hover(); + const portalRoot = page.locator('[data-web-shell-portal-root]'); + const tooltip = portalRoot.locator( + '[data-web-shell-composer-tag-tooltip]', + ); + await expect(tooltip).toBeVisible(); + await expect + .poll(async () => + tooltip.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const tolerance = 1; + return ( + getComputedStyle(element).overflowY === 'auto' && + rect.top >= 8 - tolerance && + rect.left >= 8 - tolerance && + rect.right <= window.innerWidth - 8 + tolerance && + rect.bottom <= window.innerHeight - 8 + tolerance + ); + }), + ) + .toBe(true); + } + + await attachments.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect + .poll(async () => { + const [attachmentsBox, imageBox] = await Promise.all([ + attachments.boundingBox(), + images.last().boundingBox(), + ]); + if (!attachmentsBox || !imageBox) return false; + const tolerance = 1; + return ( + imageBox.y >= attachmentsBox.y - tolerance && + imageBox.y + imageBox.height <= + attachmentsBox.y + attachmentsBox.height + tolerance + ); + }) + .toBe(true); + }); +} + +test('lets a pasted image grow the composer without collapsing the text viewport @smoke', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario(); + const daemon = await installScenario(page, scenario, testInfo); + + await gotoSession(page, scenario, daemon); + const initialHeight = await composerHeight(page); + await pasteComposerImages(page, 1); + + const image = page.locator( + '[data-web-shell-composer-surface] img[src^="data:image/png;base64,"]', + ); + await expect(image).toHaveCount(1); + await expectImagesDecoded(image); + await expect.poll(() => composerHeight(page)).toBeGreaterThan(initialHeight); + await expect + .poll(async () => { + const box = await page + .locator('[data-web-shell-composer-editor]') + .boundingBox(); + return box?.height ?? 0; + }) + .toBeGreaterThanOrEqual(44); + + await image.locator('..').getByRole('button').click(); + await expect(image).toHaveCount(0); + await expect.poll(() => composerHeight(page)).toBe(initialHeight); +}); + async function installScenario( page: Page, scenario: WebShellDaemonScenario, @@ -222,6 +422,18 @@ async function gotoSession( ); } +async function gotoComposerLayoutHarness( + page: Page, + scenario: WebShellDaemonScenario, + daemon: MockDaemonController, +): Promise { + await page.goto( + `/e2e/composer-layout-harness.html?sessionId=${encodeURIComponent(scenario.sessionId)}`, + ); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + await completeReplay(page, daemon, scenario.sessionId); +} + async function completeReplay( page: Page, daemon: MockDaemonController, @@ -247,6 +459,144 @@ async function fillComposer(page: Page, text: string): Promise { await page.keyboard.type(text); } +async function replaceComposerText(page: Page, text: string): Promise { + const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); + await editor.click(); + await page.keyboard.press( + process.platform === 'darwin' ? 'Meta+A' : 'Control+A', + ); + await page.keyboard.insertText(text); +} + +async function pasteComposerImages(page: Page, count: number): Promise { + const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); + await editor.evaluate((element, imageCount) => { + const pngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; + const binary = atob(pngBase64); + const pngBytes = Uint8Array.from(binary, (byte) => byte.charCodeAt(0)); + const clipboard = new DataTransfer(); + for (let index = 0; index < imageCount; index += 1) { + clipboard.items.add( + new File([pngBytes], `pasted-${index + 1}.png`, { type: 'image/png' }), + ); + } + element.dispatchEvent( + new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: clipboard, + }), + ); + }, count); +} + +async function expectImagesDecoded(images: Locator): Promise { + await expect + .poll(() => + images.evaluateAll((elements) => + elements.every( + (element) => + element instanceof HTMLImageElement && + element.complete && + element.naturalWidth > 0 && + element.naturalHeight > 0, + ), + ), + ) + .toBe(true); +} + +async function expectCappedComposerLayout( + page: Page, + viewportHeight: number, +): Promise { + const maximumHeight = Math.min(350, viewportHeight * 0.4); + await expect + .poll(() => composerHeight(page)) + .toBeGreaterThanOrEqual(maximumHeight - 1); + await expect + .poll(() => composerHeight(page)) + .toBeLessThanOrEqual(maximumHeight + 1); + + const surface = page.locator('[data-web-shell-composer-surface]'); + const editorHost = page.locator('[data-web-shell-composer-editor]'); + const editorArea = editorHost.locator('..'); + const scroller = editorHost.locator('.cm-scroller'); + const content = scroller.locator('.cm-content'); + const toolbar = page + .locator('[data-web-shell-composer-submit]') + .locator('..') + .locator('..'); + + await expect + .poll(async () => (await editorArea.boundingBox())?.height ?? 0) + .toBeGreaterThanOrEqual(44); + await expect(toolbar).toBeVisible(); + await expect + .poll(async () => { + const [surfaceBox, toolbarBox] = await Promise.all([ + surface.boundingBox(), + toolbar.boundingBox(), + ]); + if (!surfaceBox || !toolbarBox) return false; + return ( + toolbarBox.y >= surfaceBox.y - 1 && + toolbarBox.y + toolbarBox.height <= surfaceBox.y + surfaceBox.height + 1 + ); + }) + .toBe(true); + + await expect + .poll(() => + editorArea.evaluate((element) => getComputedStyle(element).overflowY), + ) + .toBe('clip'); + await expect + .poll(() => + editorHost.evaluate((element) => getComputedStyle(element).overflowY), + ) + .toBe('clip'); + await expect + .poll(() => + scroller.evaluate((element) => getComputedStyle(element).overflowY), + ) + .toBe('auto'); + await expect + .poll(() => + editorArea.evaluate( + (element) => element.scrollHeight <= element.clientHeight + 1, + ), + ) + .toBe(true); + await expect + .poll(() => + editorHost.evaluate( + (element) => element.scrollHeight <= element.clientHeight + 1, + ), + ) + .toBe(true); + await expect + .poll(() => + scroller.evaluate( + (element) => element.scrollHeight > element.clientHeight + 1, + ), + ) + .toBe(true); + await expect + .poll(() => scroller.evaluate((element) => element.scrollTop > 0)) + .toBe(true); + await expect(content).toBeFocused(); +} + +async function composerHeight(page: Page): Promise { + const box = await page + .locator('[data-web-shell-composer-surface]') + .boundingBox(); + if (!box) throw new Error('Expected the composer surface to be visible.'); + return box.height; +} + async function submitLocalCommand(page: Page, text: string): Promise { await fillComposer(page, text); await page.locator('[data-web-shell-composer-submit]').click();