Skip to content

Commit b0e6f3d

Browse files
committed
fix(web-shell): constrain responsive sidebar drawer
1 parent 31ad20b commit b0e6f3d

8 files changed

Lines changed: 168 additions & 15 deletions

File tree

docs/developers/daemon-ui/sidebar-customization.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -288,15 +288,19 @@ These `WebShellProps` affect sidebar behavior indirectly:
288288

289289
## Collapsed and mobile states
290290

291-
| State | Behavior |
292-
| --------- | -------------------------------------------------- |
293-
| Expanded | Full sidebar with text labels |
294-
| Collapsed | Icon-rail mode (logo, pen icon, action icons only) |
295-
| Mobile | Drawer slides from left with backdrop overlay |
291+
| State | Behavior |
292+
| --------- | ---------------------------------------------------------------------------------------------- |
293+
| Expanded | Full sidebar with text labels |
294+
| Collapsed | Icon-rail mode (logo, pen icon, action icons only) |
295+
| Mobile | Drawer uses 70% of its container, within width limits, with backdrop and footer close controls |
296296

297297
Collapse state is persisted in `localStorage` under the key
298298
`qwen-code-web-shell-sidebar-collapsed`.
299299

300+
The resized desktop width is restored only in expanded layouts. Opening or
301+
closing the mobile drawer does not overwrite that width or the persisted
302+
desktop collapse preference.
303+
300304
## Source locations
301305

302306
| Component | File |

packages/web-shell/client/App.module.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@
347347
position: fixed;
348348
top: 0;
349349
left: 0;
350+
right: 0;
350351
bottom: 0;
351352
z-index: 50;
352353
pointer-events: none;

packages/web-shell/client/App.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,7 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
10261026
onOpenDaemonStatus?: () => void;
10271027
onOpenSessions?: () => void;
10281028
onOpenSplitView?: () => void;
1029+
onMobileClose?: () => void;
10291030
onNewSession?: () => Promise<boolean> | boolean;
10301031
onLoadSession?: (sessionId: string) => Promise<void> | void;
10311032
onOpenAddWorkspace?: () => void;
@@ -1110,6 +1111,15 @@ vi.mock('./components/sidebar/WebShellSidebar', async () => {
11101111
},
11111112
'split view',
11121113
),
1114+
React.createElement(
1115+
'button',
1116+
{
1117+
'data-testid': 'close-mobile-sidebar',
1118+
type: 'button',
1119+
onClick: props.onMobileClose,
1120+
},
1121+
'close mobile sidebar',
1122+
),
11131123
);
11141124
},
11151125
};
@@ -17984,6 +17994,36 @@ describe('App session callbacks', () => {
1798417994
expect(drawer?.className).toContain('mobileDrawerForced');
1798517995
});
1798617996

17997+
it('closes the forced compact drawer from the sidebar control', async () => {
17998+
const shellRef = createRef<WebShellApi>();
17999+
const { container } = renderApp({ sidebar: true, shellRef });
18000+
await flush();
18001+
18002+
await act(async () => {
18003+
shellRef.current?.openSessionDrawer();
18004+
await Promise.resolve();
18005+
});
18006+
expect(
18007+
container.querySelector('[data-sidebar-shell][role="dialog"]'),
18008+
).not.toBeNull();
18009+
18010+
await act(async () => {
18011+
container
18012+
.querySelector<HTMLButtonElement>(
18013+
'[data-testid="close-mobile-sidebar"]',
18014+
)
18015+
?.click();
18016+
await Promise.resolve();
18017+
});
18018+
18019+
expect(
18020+
container.querySelector('[data-sidebar-shell][role="dialog"]'),
18021+
).toBeNull();
18022+
expect(
18023+
container.querySelector('[data-sidebar-shell]')?.className,
18024+
).not.toContain('mobileDrawerForced');
18025+
});
18026+
1798718027
it('does not open or lock scrolling when the sidebar is disabled', async () => {
1798818028
const previousOverflow = document.body.style.overflow;
1798918029
document.body.style.overflow = 'auto';

packages/web-shell/client/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12200,6 +12200,7 @@ export function App({
1220012200
onSessionRenameConfirmed={reconcileCatalogRename}
1220112201
onError={reportError}
1220212202
mobileOpen={mobileDrawerOpen}
12203+
onMobileClose={closeMobileDrawer}
1220312204
selectedWorkspaceCwd={selectedWorkspaceCwd}
1220412205
onSelectWorkspace={setSelectedWorkspaceCwd}
1220512206
onOpenGitDiff={(workspaceCwd) =>

packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,14 +222,18 @@ function renderSidebar(
222222
collapsed = false,
223223
props: {
224224
onSelectCurrentSession?: () => void;
225+
onCollapsedChange?: (collapsed: boolean) => void;
226+
mobileOpen?: boolean;
227+
onMobileClose?: () => void;
228+
footer?: false;
225229
sessionActions?: WebShellSidebarSessionActionsOptions;
226230
strict?: boolean;
227231
} = {},
228232
) {
229233
const sidebar = (
230234
<WebShellSidebar
231235
collapsed={collapsed}
232-
onCollapsedChange={() => {}}
236+
onCollapsedChange={props.onCollapsedChange ?? (() => {})}
233237
onOpenSettings={() => {}}
234238
onOpenDaemonStatus={() => {}}
235239
onOpenScheduledTasks={() => {}}
@@ -239,6 +243,9 @@ function renderSidebar(
239243
onNewSession={() => false}
240244
onLoadSession={loadSession}
241245
onSelectCurrentSession={props.onSelectCurrentSession}
246+
mobileOpen={props.mobileOpen}
247+
onMobileClose={props.onMobileClose}
248+
footer={props.footer}
242249
onError={() => {}}
243250
sessionActions={props.sessionActions}
244251
/>
@@ -318,6 +325,39 @@ afterEach(() => {
318325
});
319326

320327
describe('WebShellSidebar collapsed session group persistence', () => {
328+
it('uses drawer constraints and closes mobile without persisting desktop collapse', async () => {
329+
const onCollapsedChange = vi.fn();
330+
const onMobileClose = vi.fn();
331+
renderSidebar(false, {
332+
mobileOpen: true,
333+
onMobileClose,
334+
onCollapsedChange,
335+
footer: false,
336+
});
337+
await flushSidebar();
338+
339+
const sidebar = container.querySelector<HTMLElement>('aside');
340+
expect(sidebar?.className).toContain(sidebarStyles.mobileOpen);
341+
expect(
342+
sidebar?.style.getPropertyValue('--web-shell-sidebar-min-width'),
343+
).toBe('220px');
344+
expect(
345+
sidebar?.style.getPropertyValue('--web-shell-sidebar-max-width'),
346+
).toBe('512px');
347+
348+
const close = container.querySelector<HTMLButtonElement>(
349+
'button[aria-label="Collapse"]',
350+
);
351+
expect(close).not.toBeNull();
352+
click(close!);
353+
354+
expect(onMobileClose).toHaveBeenCalledOnce();
355+
expect(onCollapsedChange).not.toHaveBeenCalled();
356+
expect(
357+
window.localStorage.getItem('qwen-code-web-shell-sidebar-collapsed'),
358+
).toBeNull();
359+
});
360+
321361
it('includes secondary workspace attention without querying the primary workspace', async () => {
322362
const multiWorkspaceCapabilities = {
323363
...organizationCapabilities,

packages/web-shell/client/components/sidebar/WebShellSidebar.module.css

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@
2727
align-items: center;
2828
}
2929

30+
.sidebar.mobileOpen {
31+
width: clamp(
32+
var(--web-shell-sidebar-min-width, 220px),
33+
70%,
34+
var(--web-shell-sidebar-max-width, 420px)
35+
);
36+
min-width: clamp(
37+
var(--web-shell-sidebar-min-width, 220px),
38+
70%,
39+
var(--web-shell-sidebar-max-width, 420px)
40+
);
41+
}
42+
3043
.newChatButton,
3144
.pluginButton,
3245
.footerButton,
@@ -1799,9 +1812,6 @@
17991812
.sidebar.mobileOpen {
18001813
display: flex;
18011814
z-index: 50;
1802-
/* The width var is shared with the desktop resize handle and can be wider
1803-
than a phone viewport; cap it so the drawer never overflows the screen. */
1804-
max-width: 100vw;
18051815
}
18061816

18071817
.resizeHandle {

packages/web-shell/client/components/sidebar/WebShellSidebar.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ interface WebShellSidebarProps {
371371
theme: WebShellTheme;
372372
onThemeChange: (theme: WebShellTheme) => void;
373373
mobileOpen?: boolean;
374+
onMobileClose?: () => void;
374375
/**
375376
* Phase 4: workspace cwd picked for the next new session (undefined =
376377
* primary). Only meaningful on multi-workspace daemons.
@@ -833,6 +834,7 @@ export function WebShellSidebar({
833834
theme,
834835
onThemeChange,
835836
mobileOpen,
837+
onMobileClose,
836838
selectedWorkspaceCwd,
837839
onSelectWorkspace,
838840
onOpenGitDiff,
@@ -1832,6 +1834,8 @@ export function WebShellSidebar({
18321834
const footerTight = !collapsed && sidebarWidth < SIDEBAR_FOOTER_TIGHT_WIDTH;
18331835
const sidebarStyle = {
18341836
'--web-shell-sidebar-width': `${sidebarWidth}px`,
1837+
'--web-shell-sidebar-min-width': `${SIDEBAR_MIN_WIDTH}px`,
1838+
'--web-shell-sidebar-max-width': `${getSidebarMaxWidth()}px`,
18351839
} as CSSProperties;
18361840
const newSessionDisabled = creatingSession;
18371841

@@ -5471,7 +5475,7 @@ export function WebShellSidebar({
54715475
</SidebarSessionSurface>
54725476
</div>
54735477

5474-
{footer !== false && (
5478+
{(footer !== false || mobileOpen) && (
54755479
<div
54765480
className={cx(
54775481
styles.footer,
@@ -5575,19 +5579,27 @@ export function WebShellSidebar({
55755579
<ActivityIcon size={16} strokeWidth={1.2} />
55765580
</button>
55775581
)}
5578-
{!mobileOpen && footerItems.has('collapse') && (
5582+
{(mobileOpen || footerItems.has('collapse')) && (
55795583
<button
55805584
className={styles.collapseButton}
55815585
type="button"
55825586
title={
5583-
collapsed ? t('sidebar.expand') : t('sidebar.collapse')
5587+
mobileOpen || !collapsed
5588+
? t('sidebar.collapse')
5589+
: t('sidebar.expand')
55845590
}
55855591
aria-label={
5586-
collapsed ? t('sidebar.expand') : t('sidebar.collapse')
5592+
mobileOpen || !collapsed
5593+
? t('sidebar.collapse')
5594+
: t('sidebar.expand')
5595+
}
5596+
onClick={() =>
5597+
mobileOpen
5598+
? onMobileClose?.()
5599+
: onCollapsedChange(!collapsed)
55875600
}
5588-
onClick={() => onCollapsedChange(!collapsed)}
55895601
>
5590-
{collapsed ? (
5602+
{collapsed && !mobileOpen ? (
55915603
<PanelLeftOpenIcon size={16} strokeWidth={1.2} />
55925604
) : (
55935605
<PanelLeftCloseIcon size={16} strokeWidth={1.2} />

packages/web-shell/client/e2e/web-shell.composer.mobile.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from './utils/emptyMobileComposer';
2828

2929
const COMPOSER_TEXTAREA = 'textarea[data-web-shell-composer-editor]';
30+
const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width';
3031

3132
test('renders the textarea backend instead of CodeMirror on touch devices', async ({
3233
page,
@@ -102,6 +103,50 @@ test('keeps voice controls reachable on an extra-narrow touch viewport', async (
102103
await expect(activeToolbar.locator('button:visible')).toHaveCount(2);
103104
});
104105

106+
test('keeps a persisted wide sidebar inside the mobile drawer and exposes close', async ({
107+
page,
108+
}, testInfo) => {
109+
await page.addInitScript(
110+
([key, width]) => {
111+
window.localStorage.setItem(key, width);
112+
},
113+
[SIDEBAR_WIDTH_STORAGE_KEY, '512'] as const,
114+
);
115+
const scenario = createWebShellDaemonScenario();
116+
const daemon = await installScenario(page, scenario, testInfo);
117+
118+
await gotoSession(page, scenario, daemon);
119+
await page.getByRole('button', { name: 'Toggle menu' }).tap();
120+
121+
const drawer = page.getByRole('dialog', { name: 'Workspace sidebar' });
122+
const sidebar = page.getByRole('complementary', {
123+
name: 'Workspace sidebar',
124+
});
125+
await expect(drawer).toBeVisible();
126+
await expect(sidebar).toBeVisible();
127+
const { drawerWidth, sidebarWidth } = await sidebar.evaluate((element) => ({
128+
drawerWidth: element.parentElement?.getBoundingClientRect().width ?? 0,
129+
sidebarWidth: element.getBoundingClientRect().width,
130+
}));
131+
expect(sidebarWidth).toBeCloseTo(drawerWidth * 0.7, 0);
132+
expect(sidebarWidth).toBeLessThanOrEqual(drawerWidth);
133+
134+
await page.screenshot({
135+
path: 'client/e2e/test-results/responsive-sidebar-drawer.png',
136+
fullPage: true,
137+
});
138+
await page.getByRole('button', { name: 'Collapse' }).tap();
139+
await expect(drawer).toBeHidden();
140+
await expect
141+
.poll(() =>
142+
page.evaluate(
143+
(key) => window.localStorage.getItem(key),
144+
SIDEBAR_WIDTH_STORAGE_KEY,
145+
),
146+
)
147+
.toBe('512');
148+
});
149+
105150
test('tap, type, and Send submit through the shared prompt pipeline', async ({
106151
page,
107152
}, testInfo) => {

0 commit comments

Comments
 (0)