Repository navigation
fix(web-shell): keep mobile composer at chat pane bottom #8601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
wenshao
merged 13 commits into
QwenLM:main
from
dreamWB:codex/webshell-mobile-composer-bottom
Aug 7, 2026
Merged
Changes from 11 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
84d0a76
fix(web-shell): keep mobile composer at chat pane bottom
dreamWB 018bd31
test(web-shell): strengthen mobile composer layout coverage
dreamWB 7192202
fix(web-shell): harden mobile composer layout
dreamWB 6cd894a
fix(web-shell): center footerless mobile welcome
dreamWB 1f4eec5
test(web-shell): type mobile dot field element
dreamWB 9bd6240
fix(web-shell): harden empty mobile footer layout
dreamWB 3d91803
test(web-shell): strengthen mobile welcome overflow coverage
dreamWB 2af4fd7
refactor(web-shell): narrow mobile composer fix scope
dreamWB 1936911
fix(web-shell): keep bottom panels anchored in custom-footer mobile w…
dreamWB 837fd07
Merge branch 'main' into codex/webshell-mobile-composer-bottom
qwen-code-dev-bot 6bdce6d
test(web-shell): reject custom footer variant in composer layout helper
qwen-code-dev-bot 9023205
test(web-shell): share mobile welcome chrome checks and pin custom fo…
qwen-code-dev-bot 936e156
docs(web-shell): mark custom footer class as load-bearing for CSS exc…
qwen-code-dev-bot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
41 changes: 41 additions & 0 deletions
41
.qwen/e2e-tests/2026-08-05-webshell-mobile-composer-bottom.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
176 changes: 176 additions & 0 deletions
176
packages/web-shell/client/e2e/utils/emptyMobileComposer.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.