From 227c12195b7aab3379b43219fd1df1481f72b7d8 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Mon, 29 Jun 2026 18:08:04 +0800 Subject: [PATCH 1/9] feat(web-shell): add mobile sidebar drawer with session list Replace the display:none behavior at viewport <=760px with an overlay drawer pattern. A hamburger menu button appears on mobile, tapping it slides the existing WebShellSidebar in as a fixed overlay with a semi-transparent backdrop. Selecting or creating a session auto-closes the drawer. Desktop layout (>=761px) is unaffected. Closes #6000 --- packages/web-shell/client/App.module.css | 70 +++++++++++++++++++ packages/web-shell/client/App.tsx | 61 +++++++++++++--- .../sidebar/WebShellSidebar.module.css | 4 ++ .../components/sidebar/WebShellSidebar.tsx | 3 + packages/web-shell/client/i18n.tsx | 2 + 5 files changed, 130 insertions(+), 10 deletions(-) diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index c01ffd40491..1ca68784113 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -49,6 +49,76 @@ overflow: hidden; } +.mobileDrawer { + display: none; +} + +.mobileBackdrop { + display: none; +} + +.hamburgerButton { + display: none; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border: none; + background: transparent; + color: var(--foreground); + cursor: pointer; + flex-shrink: 0; + border-radius: var(--radius); +} + +.hamburgerButton:hover { + background: var(--accent); +} + +.hamburgerButton svg { + width: 20px; + height: 20px; +} + +@media (max-width: 760px) { + .mobileDrawer { + display: block; + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 50; + pointer-events: none; + visibility: hidden; + } + + .mobileDrawerOpen { + visibility: visible; + pointer-events: auto; + } + + .mobileBackdrop { + display: block; + position: fixed; + inset: 0; + z-index: 49; + background: rgba(0, 0, 0, 0.5); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease; + } + + .mobileDrawerOpen .mobileBackdrop { + opacity: 1; + pointer-events: auto; + } + + .hamburgerButton { + display: flex; + } +} + .appChatEmpty.appWithSidebar { justify-content: flex-start; overflow: hidden; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 8c3ba96283b..e08b8706591 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -907,6 +907,8 @@ export function App({ const [sidebarSwitchingSessionId, setSidebarSwitchingSessionId] = useState< string | null >(null); + const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); + const closeMobileDrawer = useCallback(() => setMobileDrawerOpen(false), []); const handleSidebarCollapsedChange = useCallback((collapsed: boolean) => { setSidebarCollapsed(collapsed); writeSidebarCollapsed(collapsed); @@ -2155,6 +2157,7 @@ export function App({ const session = await ( sessionActions as typeof sessionActions & SessionActionsWithCreate ).createSession(); + closeMobileDrawer(); if (onSessionIdChange) { onSessionIdChange(session.sessionId); return true; @@ -2169,7 +2172,7 @@ export function App({ reportError(error, 'Failed to create a new session'); return false; } - }, [onSessionIdChange, reportError, sessionActions]); + }, [closeMobileDrawer, onSessionIdChange, reportError, sessionActions]); const loadSidebarSession = useCallback( async (sessionId: string) => { @@ -2178,6 +2181,7 @@ export function App({ await sessionActions.loadSession(sessionId, { deferTranscriptReset: true, }); + closeMobileDrawer(); } catch (error) { setSidebarSwitchingSessionId((current) => current === sessionId ? null : current, @@ -2185,7 +2189,7 @@ export function App({ throw error; } }, - [sessionActions], + [closeMobileDrawer, sessionActions], ); useEffect(() => { @@ -3793,16 +3797,53 @@ export function App({
{sidebarOptions.enabled && ( - setShowSettingsDialog(true)} - onNewSession={createNewSession} - onLoadSession={loadSidebarSession} - onError={reportError} - /> +
+ )}
+ {sidebarOptions.enabled && ( + + )} Promise | boolean; onLoadSession: (sessionId: string) => Promise | void; onError: (error: unknown, fallback: string) => void; + mobileOpen?: boolean; } function cx(...classes: Array): string { @@ -175,6 +176,7 @@ export function WebShellSidebar({ onNewSession, onLoadSession, onError, + mobileOpen, }: WebShellSidebarProps) { const { t } = useI18n(); const connection = useConnection(); @@ -754,6 +756,7 @@ export function WebShellSidebar({ styles.sidebar, collapsed && styles.collapsed, isResizing && styles.resizing, + mobileOpen && styles.mobileOpen, )} aria-label={t('sidebar.label')} style={sidebarStyle} diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index a273b91b0a7..0df305a0286 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -413,6 +413,7 @@ const EN: Messages = { 'quickActions.exitShellMode': 'Exit Shell', 'quickActions.setGoal': 'Set goal', 'sidebar.label': 'Workspace sidebar', + 'sidebar.toggleMenu': 'Toggle menu', 'sidebar.newChat': 'New chat', 'sidebar.project': 'Project', 'sidebar.projectFallback': 'Project', @@ -1603,6 +1604,7 @@ const ZH: Messages = { 'quickActions.exitShellMode': '退出Shell', 'quickActions.setGoal': '设置目标', 'sidebar.label': '工作区侧边栏', + 'sidebar.toggleMenu': '切换菜单', 'sidebar.newChat': '新对话', 'sidebar.project': '项目', 'sidebar.projectFallback': '项目', From ea5062b65e2aaee58eba19b2ebaa84f959b24025 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Tue, 30 Jun 2026 06:55:11 +0800 Subject: [PATCH 2/9] fix(web-shell): address review feedback for mobile sidebar drawer - Use display:contents for desktop wrapper transparency (Critical: sidebar was hidden) - Fix z-index stacking so sidebar renders above backdrop in drawer - Force sidebar expand when mobile drawer is open (collapsed state) - Hide resizeHandle on mobile to prevent touch scroll conflicts - Reset drawer state on viewport resize via matchMedia listener - Add role=dialog, aria-modal, Escape key dismissal, body scroll lock - Add aria-expanded to hamburger button - Close drawer when opening Settings or resuming sessions --- packages/web-shell/client/App.module.css | 7 +++- packages/web-shell/client/App.tsx | 40 ++++++++++++++++++- .../sidebar/WebShellSidebar.module.css | 4 ++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index 1ca68784113..9f8d88b3b51 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -50,7 +50,7 @@ } .mobileDrawer { - display: none; + display: contents; } .mobileBackdrop { @@ -114,6 +114,11 @@ pointer-events: auto; } + .mobileDrawer :global(.sidebar) { + position: relative; + z-index: 1; + } + .hamburgerButton { display: flex; } diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index e08b8706591..58ec950d6f3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -909,6 +909,32 @@ export function App({ >(null); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const closeMobileDrawer = useCallback(() => setMobileDrawerOpen(false), []); + + useEffect(() => { + const mql = window.matchMedia('(max-width: 760px)'); + const handler = (e: MediaQueryListEvent) => { + if (!e.matches) setMobileDrawerOpen(false); + }; + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, []); + + useEffect(() => { + if (!mobileDrawerOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + closeMobileDrawer(); + } + }; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + window.addEventListener('keydown', onKey, true); + return () => { + document.body.style.overflow = prevOverflow; + window.removeEventListener('keydown', onKey, true); + }; + }, [mobileDrawerOpen, closeMobileDrawer]); const handleSidebarCollapsedChange = useCallback((collapsed: boolean) => { setSidebarCollapsed(collapsed); writeSidebarCollapsed(collapsed); @@ -2919,6 +2945,7 @@ export function App({ if (cmd === 'resume') { const sessionId = text.slice(match[0].length).trim(); if (sessionId) { + closeMobileDrawer(); sessionActions.loadSession(sessionId).catch((error: unknown) => { reportError(error, 'Failed to load session'); }); @@ -3102,6 +3129,7 @@ export function App({ enqueuePrompt, echoOrDeferLocalCommand, branchCurrentSession, + closeMobileDrawer, createNewSession, handleBusyGoalClear, handleGoalSlashCommand, @@ -3525,6 +3553,7 @@ export function App({ > { + closeMobileDrawer(); sessionActions .loadSession(sessionId) .catch((error: unknown) => { @@ -3798,6 +3827,9 @@ export function App({
{sidebarOptions.enabled && (