Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
33 changes: 33 additions & 0 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,39 @@
display: none;
}

/* Hosts can embed WebShell in a narrow side pane while the browser viewport
* remains wide. The public drawer API must still show a usable overlay in
* that container instead of relying exclusively on the viewport media query. */
.mobileDrawerForced {
display: block;
/* `.app` is the narrow host Webview's positioning context. Absolute keeps
* the drawer inside it; fixed would target the IDE's larger page instead. */
position: absolute;
inset: 0;
z-index: 50;
pointer-events: auto;
visibility: visible;
padding-top: env(safe-area-inset-top);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
}

.mobileDrawerForced .mobileBackdrop {
display: block;
position: absolute;
inset: 0;
z-index: 49;
background: rgba(0, 0, 0, 0.5);
opacity: 1;
pointer-events: auto;
}

.mobileDrawerForced > aside {
position: relative;
z-index: 50;
}

.hamburgerButton {
display: none;
align-items: center;
Expand Down
136 changes: 136 additions & 0 deletions packages/web-shell/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1506,6 +1506,142 @@ describe('App session callbacks', () => {
expect(panel?.getAttribute('aria-label')).toBe('Session Overview');
});

it('forces the compact session drawer from the external shell ref', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});

const drawer = container.querySelector(
'[data-sidebar-shell][role="dialog"]',
);
expect(drawer).not.toBeNull();
expect(drawer?.className).toContain('mobileDrawerForced');
});

it('returns a forced compact drawer to viewport control when the user dismisses it', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).toContain('mobileDrawerForced');

await act(async () => {
container
.querySelector<HTMLElement>(
'[data-sidebar-shell] > div[aria-hidden="true"]',
)
?.click();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
});

it('returns to chat and clears the current page when the external shell opens the compact drawer', async () => {
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionOverview();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="inline-panel"]'),
).not.toBeNull();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull();

await act(async () => {
shellRef.current?.openSplitView();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).not.toBeNull();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-testid="split-view-page"]'),
).toBeNull();
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).not.toBeNull();
});

it('clears a forced compact drawer after crossing to a wide viewport', async () => {
let mobileChangeHandler:
| ((event: { matches: boolean }) => void)
| undefined;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes('min-width'),
media: query,
addEventListener: (
_type: string,
handler: (event: { matches: boolean }) => void,
) => {
if (query.includes('max-width')) mobileChangeHandler = handler;
},
removeEventListener: vi.fn(),
})),
});
const shellRef = createRef<WebShellApi>();
const { container } = renderApp({ sidebar: true, shellRef });
await flush();

await act(async () => {
shellRef.current?.openSessionDrawer();
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).toContain('mobileDrawerForced');

await act(async () => {
mobileChangeHandler?.({ matches: false });
await Promise.resolve();
});
expect(
container.querySelector('[data-sidebar-shell]')?.className,
).not.toContain('mobileDrawerForced');
expect(
container.querySelector('[data-sidebar-shell][role="dialog"]'),
).toBeNull();
});

it('lets a host hide the built-in compact sidebar toggle', async () => {
const { container } = renderApp({
sidebar: { enabled: true, showCompactToggle: false },
});
await flush();

expect(container.querySelector('[aria-label="Toggle menu"]')).toBeNull();
});

it('returns to the Session Overview when leaving the split view', async () => {
const { container } = renderApp();
await flush();
Expand Down
56 changes: 46 additions & 10 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ import { ThemeDialog } from './components/dialogs/ThemeDialog';
import { DeleteSessionDialog } from './components/dialogs/DeleteSessionDialog';
import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog';
import { RewindDialog } from './components/dialogs/RewindDialog';
import { WebShellSidebar } from './components/sidebar/WebShellSidebar';
import {
WebShellSidebar,
type WebShellSidebarBranding,
type WebShellSidebarFooterOptions,
} from './components/sidebar/WebShellSidebar';
import {
getLocalCommands,
localizeBuiltinDescriptions,
Expand Down Expand Up @@ -401,6 +405,12 @@ export interface BugReportInfo {
export interface WebShellSidebarOptions {
enabled?: boolean;
defaultCollapsed?: boolean;
/** Whether to show WebShell's built-in compact drawer toggle. Defaults to true. */
showCompactToggle?: boolean;
/** Hide or replace the leading New Chat brand mark. */
branding?: false | WebShellSidebarBranding;
/** Hide the footer completely or select the built-in entries it exposes. */
footer?: false | WebShellSidebarFooterOptions;
}

export type SessionChangeEvent =
Expand All @@ -413,6 +423,8 @@ export interface WebShellApi {
openSplitView: () => void;
/** Open the Session Overview panel, matching the built-in sidebar button. */
openSessionOverview: () => void;
/** Open the compact session drawer, matching the hamburger control. */
openSessionDrawer: () => void;
}

export interface WebShellProps {
Expand Down Expand Up @@ -579,18 +591,25 @@ const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width';
const CHAT_SHELL_HORIZONTAL_PADDING = 40;
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'qwen-code-web-shell-sidebar-collapsed';

function resolveSidebarOptions(
sidebar: WebShellProps['sidebar'],
): Required<WebShellSidebarOptions> {
function resolveSidebarOptions(sidebar: WebShellProps['sidebar']): {
enabled: boolean;
defaultCollapsed: boolean;
showCompactToggle: boolean;
branding?: false | WebShellSidebarBranding;
footer?: false | WebShellSidebarFooterOptions;
} {
if (sidebar === true) {
return { enabled: true, defaultCollapsed: false };
return { enabled: true, defaultCollapsed: false, showCompactToggle: true };
}
if (!sidebar) {
return { enabled: false, defaultCollapsed: false };
return { enabled: false, defaultCollapsed: false, showCompactToggle: true };
}
return {
enabled: sidebar.enabled ?? true,
defaultCollapsed: sidebar.defaultCollapsed ?? false,
showCompactToggle: sidebar.showCompactToggle ?? true,
branding: sidebar.branding,
footer: sidebar.footer,
};
}

Expand Down Expand Up @@ -975,7 +994,11 @@ export function App({
string | null
>(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const closeMobileDrawer = useCallback(() => setMobileDrawerOpen(false), []);
const [forceMobileDrawer, setForceMobileDrawer] = useState(false);
const closeMobileDrawer = useCallback(() => {
setMobileDrawerOpen(false);
setForceMobileDrawer(false);
}, []);
// The Session Overview panel (mission control for managing many sessions at
// once) is only offered on large screens; below that there is no room for it
// to be useful.
Expand All @@ -984,11 +1007,11 @@ export function App({
useEffect(() => {
const mql = window.matchMedia('(max-width: 760px)');
const handler = (e: MediaQueryListEvent) => {
if (!e.matches) setMobileDrawerOpen(false);
if (!e.matches) closeMobileDrawer();
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
}, [closeMobileDrawer]);

useEffect(() => {
if (!mobileDrawerOpen) return;
Expand Down Expand Up @@ -1999,6 +2022,12 @@ export function App({
() => ({
openSplitView: () => requestOpenSplitView(),
openSessionOverview: () => openPanel('sessions'),
openSessionDrawer: () => {
Comment thread
dreamWB marked this conversation as resolved.
setActivePanel(null);
setMainView('chat');
setForceMobileDrawer(true);
Comment thread
dreamWB marked this conversation as resolved.
setMobileDrawerOpen(true);
},
}),
[openPanel, requestOpenSplitView],
);
Expand Down Expand Up @@ -5356,6 +5385,7 @@ export function App({
className={[
styles.mobileDrawer,
mobileDrawerOpen ? styles.mobileDrawerOpen : undefined,
forceMobileDrawer ? styles.mobileDrawerForced : undefined,
]
.filter(Boolean)
.join(' ')}
Expand Down Expand Up @@ -5403,6 +5433,8 @@ export function App({
sessionListReloadToken={sessionListReloadToken}
selectedWorkspaceCwd={selectedWorkspaceCwd}
onSelectWorkspace={setSelectedWorkspaceCwd}
branding={sidebarOptions.branding}
footer={sidebarOptions.footer}
/>
</div>
)}
Expand All @@ -5419,6 +5451,7 @@ export function App({
.join(' ')}
>
{sidebarOptions.enabled &&
sidebarOptions.showCompactToggle &&
!activePanel &&
mainView === 'chat' && (
<button
Expand All @@ -5431,7 +5464,10 @@ export function App({
]
.filter(Boolean)
.join(' ')}
onClick={() => setMobileDrawerOpen((open) => !open)}
onClick={() => {
setForceMobileDrawer(false);
setMobileDrawerOpen((open) => !open);
}}
aria-label={t('sidebar.toggleMenu')}
aria-expanded={mobileDrawerOpen}
>
Expand Down
Loading
Loading