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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .qwen/e2e-tests/2026-08-05-webshell-mobile-composer-bottom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Narrow empty-session composer anchoring

## Scenario

Open the WebShell composer-layout harness without a session, with
`mobileWelcomeFooterMiddle` enabled and welcome header/footer renderers. At a
760px viewport width, the empty chat pane uses the mobile composer-bottom
layout. Resize to 761px and back to 760px.

## Checks

- The absolute composer footer aligns with the full chat-pane bottom at 760px.
- The welcome header stays above the composer; the dot field covers the pane,
stays behind the foreground, and does not capture pointer events.
- The composer accepts text input.
- At 761px, the mobile absolute-footer rule is inactive; resizing back restores
the bottom anchoring.

## Evidence

Before the CSS repair, the internal Chromium harness measured a 419px gap
between the composer footer bottom and the chat-pane bottom at 760x900. The
focused upstream command is:

```sh
npx playwright test --config packages/web-shell/playwright.config.ts packages/web-shell/client/e2e/web-shell.smoke.spec.ts --grep 'anchors the empty mobile composer'
```

After the repair, the focused upstream run passed (`1 passed`, 4.5s). The
embedded extension-host browser verification also passed in Chrome on macOS
using the naturally narrow Data Agent sidebar: the welcome content stayed
centered, the composer aligned with the sidebar bottom, the dot field remained
behind the foreground, and a temporary draft could be entered and cleared
without submission.

A baseline dry-run against the global `qwen` CLI is not applicable to this
host-only layout path. The CLI does not render the WebShell DOM or expose the
host props `mobileWelcomeFooterMiddle`, `renderWelcomeHeader`, and
`renderWelcomeFooter` needed to trigger it. The executable baseline is instead
the real Chromium composer-layout harness above, which reproduced the defect as
a 419px footer-to-pane bottom gap before the CSS repair.
14 changes: 14 additions & 0 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,20 @@
overflow: hidden;
}

/* Override chatViewWrap's relative positioning so the absolute footer
anchors to chatPane. The wrap remains a flex item, so the z-index: 1 it
gets from `.appChatEmpty .chatViewWrap` still applies under static
positioning and keeps the welcome content and composer above the dot
field. The welcome-middle + custom-footer variant keeps the positioned
wrap: `.footerWithCustomFooter` renders `display: contents` there, so
the wrap must remain the containing block for `.bottomPanels`. */
.appChatEmpty
.chatViewWithMobileComposerBottom:not(
.chatViewWithWelcomeMiddle.chatViewWithCustomFooter
) {
position: static;
}
Comment thread
dreamWB marked this conversation as resolved.

.appChatEmpty .chatViewWithMobileComposerBottom .footer {
position: absolute;
right: 0;
Expand Down
1 change: 1 addition & 0 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9824,6 +9824,7 @@ export function App({
hasWelcomeMiddle
? styles.chatViewWithWelcomeMiddle
: undefined,
CustomFooter ? styles.chatViewWithCustomFooter : undefined,
activePanel || mainView !== 'chat'
? styles.chatViewHidden
: undefined,
Expand Down
28 changes: 27 additions & 1 deletion packages/web-shell/client/e2e/composer-layout-harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,48 @@ const indexEntry = '../index.tsx';
const { WebShellWithProviders } = await import(/* @vite-ignore */ indexEntry);

const params = new URLSearchParams(window.location.search);
const emptyMobileWelcome = params.get('emptyMobileWelcome') === 'true';
const includeWelcomeFooter = params.get('welcomeFooter') !== 'false';
const includeCustomFooter = params.get('customFooter') === 'true';
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}`,
}));
const sessionProps = emptyMobileWelcome
? {
mobileWelcomeFooterMiddle: true,
Comment thread
dreamWB marked this conversation as resolved.
renderWelcomeHeader: () => (
<div data-e2e-mobile-welcome-header>Welcome header</div>
),
...(includeWelcomeFooter
? {
renderWelcomeFooter: () => (
<div data-e2e-mobile-welcome-footer>Welcome footer</div>
),
}
: {}),
...(includeCustomFooter
? {
renderFooter: () => <div data-e2e-custom-footer>Custom footer</div>,
bottomStatusItems: [
{ id: 'composer-layout-status', label: 'Bottom status item' },
],
}
: {}),
}
: { sessionId };

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<WebShellWithProviders
baseUrl={window.location.origin}
sessionId={sessionId}
sidebar={false}
composerInput={{ tags, tagPlacement: 'top' }}
composerInputVersion={1}
renderComposerTagTooltip={({ tag }) => `Details for ${tag.value}`}
{...sessionProps}
/>
</React.StrictMode>,
);
176 changes: 176 additions & 0 deletions packages/web-shell/client/e2e/utils/emptyMobileComposer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { expect, type Page } from '@playwright/test';

export const emptyMobileComposerSelectors = {
composerSurface: '[data-web-shell-composer-surface]',
dotField: '[data-web-shell-new-session-dot-field]',
welcomeFooter: '[data-e2e-mobile-welcome-footer]',
welcomeHeader: '[data-e2e-mobile-welcome-header]',
} as const;

export interface EmptyMobileComposerLayout {
chatPaneBottom: number;
chatViewIsPaneFlexItem: boolean;
chatViewPosition: string;
chatViewZIndex: string;
composerTop: number;
dotFieldAnchoredToChatPane: boolean;
dotFieldCoversChatPane: boolean;
dotFieldPointerEvents: string;
dotFieldZIndex: string;
footerAnchoredToChatPane: boolean;
footerBottom: number;
footerPosition: string;
welcomeFooterBottom: number | null;
welcomeFooterTop: number | null;
welcomeHeaderBottom: number;
}

export interface EmptyMobileComposerLayoutOptions {
requireWelcomeFooter?: boolean;
}

export interface EmptyMobileWelcomeHarnessOptions {
customFooter?: boolean;
welcomeFooter?: boolean;
}

export async function gotoEmptyMobileWelcomeHarness(
page: Page,
options: EmptyMobileWelcomeHarnessOptions = {},
): Promise<void> {
const params = new URLSearchParams({ emptyMobileWelcome: 'true' });
if (options.welcomeFooter === false) params.set('welcomeFooter', 'false');
if (options.customFooter === true) params.set('customFooter', 'true');
await page.goto(`/e2e/composer-layout-harness.html?${params.toString()}`);
await expect(page.locator('[data-web-shell-root]')).toBeVisible();
}

export async function emptyMobileComposerLayout(
page: Page,
options: EmptyMobileComposerLayoutOptions = {},
): Promise<EmptyMobileComposerLayout> {
return page.getByTestId('chat-pane-container').evaluate(
(chatPane, { selectors, requireWelcomeFooter }) => {
const composer = chatPane.querySelector(selectors.composerSurface);
const welcomeHeader = chatPane.querySelector(selectors.welcomeHeader);
const welcomeFooter = Array.from(
chatPane.querySelectorAll<HTMLElement>(selectors.welcomeFooter),
).find((candidate) => candidate.getClientRects().length > 0);
const dotField = chatPane.querySelector<HTMLElement>(selectors.dotField);
if (!composer) {
throw new Error('Expected the empty mobile composer to be rendered.');
}
if (!welcomeHeader) {
throw new Error('Expected the mobile welcome header to be rendered.');
}
if (!dotField) {
throw new Error('Expected the new-session dot field to be rendered.');
}
if (requireWelcomeFooter && !welcomeFooter) {
throw new Error('Expected the mobile welcome footer to be rendered.');
}

const chatView = Array.from(chatPane.children).find((child) =>
child.contains(composer),
);
if (!chatView) throw new Error('Expected the composer chat view.');

const composerShell = composer.closest('[data-web-shell-composer]');
if (!composerShell) throw new Error('Expected the composer shell.');

let footer: HTMLElement | undefined;
for (
let ancestor = composerShell.parentElement;
ancestor && ancestor !== chatPane;
ancestor = ancestor.parentElement
) {
const style = getComputedStyle(ancestor);
if (style.position === 'absolute' || style.position === 'relative') {
footer = ancestor;
break;
}
}
if (!footer) throw new Error('Expected the composer footer.');
if (footer.getClientRects().length === 0) {
throw new Error(
'The composer footer has no box in the custom footer welcome variant (it renders display: contents by design), so anchored layout measurements do not apply there.',
);
}

const chatPaneRect = chatPane.getBoundingClientRect();
const chatPaneStyle = getComputedStyle(chatPane);
const chatViewStyle = getComputedStyle(chatView);
const dotFieldRect = dotField.getBoundingClientRect();
const dotFieldStyle = getComputedStyle(dotField);
const footerRect = footer.getBoundingClientRect();
const welcomeFooterRect = welcomeFooter?.getBoundingClientRect();

return {
chatPaneBottom: chatPaneRect.bottom,
chatViewIsPaneFlexItem:
chatView.parentElement === chatPane &&
chatPaneStyle.display === 'flex',
chatViewPosition: chatViewStyle.position,
chatViewZIndex: chatViewStyle.zIndex,
composerTop: composer.getBoundingClientRect().top,
dotFieldAnchoredToChatPane: dotField.offsetParent === chatPane,
dotFieldCoversChatPane:
Math.abs(dotFieldRect.top - chatPaneRect.top) <= 1 &&
Math.abs(dotFieldRect.right - chatPaneRect.right) <= 1 &&
Math.abs(dotFieldRect.bottom - chatPaneRect.bottom) <= 1 &&
Math.abs(dotFieldRect.left - chatPaneRect.left) <= 1,
dotFieldPointerEvents: dotFieldStyle.pointerEvents,
dotFieldZIndex: dotFieldStyle.zIndex,
footerAnchoredToChatPane: footer.offsetParent === chatPane,
footerBottom: footerRect.bottom,
footerPosition: getComputedStyle(footer).position,
welcomeFooterBottom: welcomeFooterRect?.bottom ?? null,
welcomeFooterTop: welcomeFooterRect?.top ?? null,
welcomeHeaderBottom: welcomeHeader.getBoundingClientRect().bottom,
};
},
{
selectors: emptyMobileComposerSelectors,
requireWelcomeFooter: options.requireWelcomeFooter !== false,
},
);
}

export function expectEmptyMobileComposerAnchored(
layout: EmptyMobileComposerLayout,
options: EmptyMobileComposerLayoutOptions = {},
): void {
expect(layout.footerPosition).toBe('absolute');
expect(
Math.abs(layout.footerBottom - layout.chatPaneBottom),
).toBeLessThanOrEqual(1);
expect(layout.chatViewPosition).toBe('static');
expect(layout.chatViewIsPaneFlexItem).toBe(true);
expect(layout.chatViewZIndex).toBe('1');
expect(layout.footerAnchoredToChatPane).toBe(true);
expect(layout.dotFieldAnchoredToChatPane).toBe(true);
expect(layout.dotFieldCoversChatPane).toBe(true);
expect(layout.dotFieldPointerEvents).toBe('none');
expect(Number(layout.chatViewZIndex)).toBeGreaterThan(
Number(layout.dotFieldZIndex),
);

if (options.requireWelcomeFooter !== false) {
if (
layout.welcomeFooterTop === null ||
layout.welcomeFooterBottom === null
) {
throw new Error('Expected a visible welcome footer.');
}
expect(layout.welcomeHeaderBottom).toBeLessThanOrEqual(
layout.welcomeFooterTop,
);
expect(layout.welcomeFooterBottom).toBeLessThanOrEqual(layout.composerTop);
}
}
40 changes: 40 additions & 0 deletions packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ import {
type MockDaemonController,
type WebShellDaemonScenario,
} from './utils/mockDaemon';
import {
emptyMobileComposerLayout,
emptyMobileComposerSelectors,
expectEmptyMobileComposerAnchored,
gotoEmptyMobileWelcomeHarness,
} from './utils/emptyMobileComposer';

const COMPOSER_TEXTAREA = 'textarea[data-web-shell-composer-editor]';

Expand All @@ -37,6 +43,40 @@ test('renders the textarea backend instead of CodeMirror on touch devices', asyn
).toHaveCount(0);
});

test('anchors the empty mobile composer with the textarea backend', async ({
page,
}, testInfo) => {
const scenario = createWebShellDaemonScenario();
await installScenario(page, scenario, testInfo);

await gotoEmptyMobileWelcomeHarness(page);
const textarea = page.locator(COMPOSER_TEXTAREA);
const composer = page.locator(emptyMobileComposerSelectors.composerSurface);
const welcomeHeader = page.locator(
emptyMobileComposerSelectors.welcomeHeader,
);
const welcomeFooter = page.locator(
`${emptyMobileComposerSelectors.welcomeFooter}:visible`,
);
const dotField = page.locator(emptyMobileComposerSelectors.dotField);

await expect(textarea).toBeVisible();
await expect(page.locator('.cm-editor')).toHaveCount(0);
await expect(composer).toBeVisible();
await expect(welcomeHeader).toBeVisible();
await expect(welcomeFooter).toBeVisible();
await expect(dotField.locator('canvas')).toBeVisible();

const layout = await emptyMobileComposerLayout(page);
expectEmptyMobileComposerAnchored(layout);

await textarea.tap();
await textarea.fill('Composer remains interactive on touch devices');
await expect(textarea).toHaveValue(
'Composer remains interactive on touch devices',
);
});

test('keeps the composer usable after WebGL context loss', async ({
page,
}, testInfo) => {
Expand Down
Loading
Loading