feat(web-shell): add mobile sidebar drawer with session list (#6003)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

* 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

* 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

* fix(web-shell): address second round of review feedback

- Remove dead :global(.sidebar) selector (CSS Modules hash class names)
- Fix Escape key capture-phase handler to not intercept sidebar inputs
- Conditionally apply role=dialog/aria-modal only when drawer is open
- Stop toggling collapsed prop on drawer open/close to preserve sidebar state
- Add closeMobileDrawer() for bare /resume command path
- Fix hamburger button vertical centering in empty chat state on mobile

* fix(web-shell): fix stacking context and escape handler in mobile drawer

* fix(web-shell): prevent iOS Safari background scroll when drawer is open

* chore: remove accidentally committed .qwen-session and gitignore it

The .qwen-session file is a developer-local session UUID generated by
qwen serve. It was accidentally committed to the repo and should never
be tracked.

* fix(web-shell): address review feedback for mobile drawer

- Don't preventDefault touchmove inside the drawer so the session list
  can scroll natively; only block scrolling on the page behind it.
- Defer Escape to a pending tool/permission approval (reject) instead of
  closing the drawer when a prompt is visible.
- Reuse isEditableTarget from utils/dom and only bail out for editable
  targets outside the drawer, so the drawer search input still closes on
  the first Escape.
- Close the drawer before awaiting loadSession so it doesn't linger over
  the old transcript, matching the other session-switch paths.
- Keep the drawer panel visible until the backdrop finishes fading out to
  avoid a one-frame flicker on close.

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* fix(web-shell): mobile drawer ignores collapsed rail + block backdrop scroll

- collapsed: a user who collapsed the desktop sidebar got a mobile drawer that
  still rendered as the icon rail (no session list — the whole point of the
  drawer). Force the expanded layout while the drawer is open.
- touchmove: the allowlist matched the outer [data-mobile-drawer] wrapper, which
  also contains the full-screen backdrop, so a touchmove starting on the dim
  backdrop skipped preventDefault and let iOS Safari scroll the page behind.
  Exclude the backdrop so only the panel keeps native scroll.

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

* fix(web-shell): harden mobile drawer collapse, error path, and width cap

- Hide the sidebar collapse button while the mobile drawer is open so its
  no-op toggle can no longer silently persist desktop collapsed state.
- Close the drawer before awaiting createSession() so a failed create no
  longer leaves the drawer stuck open with page scroll locked.
- Drop redundant width/min-width/position from .sidebar.mobileOpen and cap
  it with max-width:100vw so a wide persisted width can't overflow phones.

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>

---------

Co-authored-by: pomelo-nwu <czynwu@gmail.com>
Co-authored-by: Qwen-Coder <noreply@qwen.ai>
This commit is contained in:
pomelo 2026-06-30 23:34:10 +08:00 committed by GitHub
parent a756676b21
commit 7b9e31885b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 244 additions and 27 deletions

3
.gitignore vendored
View file

@ -51,6 +51,9 @@ package-lock.json
!.qwen/team-memory/
!.qwen/team-memory/**
# Developer-local session identifier (auto-generated by qwen serve).
.qwen-session
# OS metadata
.DS_Store
Thumbs.db

View file

@ -116,7 +116,7 @@ Settings are organized into categories. Most settings should be placed within th
| `ui.renderMode` | string | Default Markdown display mode. Use `"render"` for rich visual previews or `"raw"` to show source-oriented Markdown by default. Toggle during a session with `Alt/Option+M`; on macOS the terminal must send Option as Meta. See [Markdown Rendering](../features/markdown-rendering). | `"render"` |
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` |
| `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` |
| `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` |
| `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` |
| `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `false` |
| `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` |
| `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |

View file

@ -189,12 +189,7 @@ function truncateMiddle(input: string, max: number): string {
export const InlineParallelAgentsDisplay: React.FC<
InlineParallelAgentsDisplayProps
> = ({
toolCalls,
contentWidth,
totalAgentCount,
availableTerminalHeight,
}) => {
> = ({ toolCalls, contentWidth, totalAgentCount, availableTerminalHeight }) => {
const config = useContext(ConfigContext);
// Static slice of agent calls for this group. The caller already

View file

@ -289,7 +289,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// (>=100) here — forwarding that would let the cap fire on scrollback
// and permanently hide completed agents behind "+N more". Pass
// undefined (no cap) when committed, per the component's contract.
availableTerminalHeight={isPending ? availableTerminalHeight : undefined}
availableTerminalHeight={
isPending ? availableTerminalHeight : undefined
}
/>
);
}

View file

@ -49,6 +49,81 @@
overflow: hidden;
}
.mobileDrawer {
display: contents;
}
.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;
/* Keep the panel visible until the backdrop finishes fading out so they
disappear together instead of the panel vanishing a frame early. */
transition: visibility 0s linear 200ms;
}
.mobileDrawerOpen {
visibility: visible;
pointer-events: auto;
/* Show immediately on open; only the close path needs the delay above. */
transition-delay: 0s;
}
.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;
@ -59,6 +134,14 @@
overflow-y: auto;
}
@media (max-width: 760px) {
.appChatEmpty .hamburgerButton {
position: sticky;
top: 0;
z-index: 1;
}
}
.app,
.app * {
box-sizing: border-box;

View file

@ -90,6 +90,7 @@ import {
copyFromLastAssistantMessage,
COPY_MESSAGES,
} from './utils/copyCommand';
import { isEditableTarget } from './utils/dom';
import { getModelDisplayName } from './utils/modelDisplay';
import { filterModelSwitchMessages } from './utils/modelSwitchMessages';
import { decideEscapeIntent } from './utils/escapeIntent';
@ -795,6 +796,63 @@ export function App({
const [sidebarSwitchingSessionId, setSidebarSwitchingSessionId] = useState<
string | null
>(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') return;
// A pending tool/permission approval owns Escape (it rejects the call),
// so don't let the drawer swallow it while a prompt is visible.
if (pendingApprovalRef.current) return;
const target = e.target as HTMLElement | null;
// Only let an editable element keep Escape for itself when it lives
// outside the drawer; the drawer's own search input should still close
// the drawer on the first Escape.
if (
isEditableTarget(target) &&
!target?.closest('[data-mobile-drawer]')
) {
return;
}
e.stopPropagation();
e.preventDefault();
closeMobileDrawer();
};
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const preventScroll = (e: TouchEvent) => {
// Allow native scrolling inside the drawer panel (e.g. the session list).
// The dim backdrop also lives under [data-mobile-drawer], so exclude it:
// a touchmove starting on the backdrop must still be blocked, otherwise
// iOS Safari scrolls the page behind the open drawer.
const el = e.target as HTMLElement | null;
if (
el?.closest('[data-mobile-drawer]') &&
!el.closest(`.${styles.mobileBackdrop}`)
) {
return;
}
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
window.addEventListener('keydown', onKey, true);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener('touchmove', preventScroll);
window.removeEventListener('keydown', onKey, true);
};
}, [mobileDrawerOpen, closeMobileDrawer]);
const handleSidebarCollapsedChange = useCallback((collapsed: boolean) => {
setSidebarCollapsed(collapsed);
writeSidebarCollapsed(collapsed);
@ -1870,6 +1928,9 @@ export function App({
}, [branchCurrentSession]);
const createNewSession = useCallback(async () => {
// Close the drawer before awaiting so a failed createSession() doesn't leave
// it stuck open with the page scroll still locked, matching loadSidebarSession.
closeMobileDrawer();
try {
const session = await (
sessionActions as typeof sessionActions & SessionActionsWithCreate
@ -1888,11 +1949,15 @@ 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) => {
setSidebarSwitchingSessionId(sessionId);
// Close the drawer before awaiting the load so it doesn't linger over the
// old transcript while the new session streams in, matching the other
// session-switch paths (/resume, ResumeDialog).
closeMobileDrawer();
try {
await sessionActions.loadSession(sessionId, {
deferTranscriptReset: true,
@ -1904,7 +1969,7 @@ export function App({
throw error;
}
},
[sessionActions],
[closeMobileDrawer, sessionActions],
);
useEffect(() => {
@ -2633,10 +2698,12 @@ 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');
});
} else {
closeMobileDrawer();
setShowResumeDialog(true);
}
return true;
@ -2819,6 +2886,7 @@ export function App({
enqueuePrompt,
echoOrDeferLocalCommand,
branchCurrentSession,
closeMobileDrawer,
createNewSession,
handleBusyGoalClear,
handleGoalSlashCommand,
@ -3216,6 +3284,7 @@ export function App({
>
<ResumeDialog
onSelect={(sessionId) => {
closeMobileDrawer();
sessionActions
.loadSession(sessionId)
.catch((error: unknown) => {
@ -3488,16 +3557,62 @@ export function App({
<div className={styles.appShell}>
{sidebarOptions.enabled && (
<WebShellSidebar
collapsed={sidebarCollapsed}
onCollapsedChange={handleSidebarCollapsedChange}
onOpenSettings={() => setShowSettingsDialog(true)}
onNewSession={createNewSession}
onLoadSession={loadSidebarSession}
onError={reportError}
/>
<div
data-mobile-drawer=""
{...(mobileDrawerOpen
? { role: 'dialog', 'aria-modal': 'true' as const }
: {})}
aria-label={t('sidebar.label')}
className={[
styles.mobileDrawer,
mobileDrawerOpen ? styles.mobileDrawerOpen : undefined,
]
.filter(Boolean)
.join(' ')}
>
<div
className={styles.mobileBackdrop}
onClick={closeMobileDrawer}
aria-hidden="true"
/>
<WebShellSidebar
collapsed={sidebarCollapsed && !mobileDrawerOpen}
onCollapsedChange={handleSidebarCollapsedChange}
onOpenSettings={() => {
closeMobileDrawer();
setShowSettingsDialog(true);
}}
onNewSession={createNewSession}
onLoadSession={loadSidebarSession}
onError={reportError}
mobileOpen={mobileDrawerOpen}
/>
</div>
)}
<div className={styles.chatPane}>
{sidebarOptions.enabled && (
<button
type="button"
className={styles.hamburgerButton}
onClick={() => setMobileDrawerOpen((open) => !open)}
aria-label={t('sidebar.toggleMenu')}
aria-expanded={mobileDrawerOpen}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<WebShellCustomizationProvider value={customization}>
<CompactModeContext.Provider value={compactMode}>
<TodoContextsProvider

View file

@ -632,4 +632,16 @@
.sidebar {
display: none;
}
.sidebar.mobileOpen {
display: flex;
z-index: 50;
/* The width var is shared with the desktop resize handle and can be wider
than a phone viewport; cap it so the drawer never overflows the screen. */
max-width: 100vw;
}
.resizeHandle {
display: none;
}
}

View file

@ -36,6 +36,7 @@ interface WebShellSidebarProps {
onNewSession: () => Promise<boolean> | boolean;
onLoadSession: (sessionId: string) => Promise<void> | void;
onError: (error: unknown, fallback: string) => void;
mobileOpen?: boolean;
}
function cx(...classes: Array<string | false | undefined>): 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}
@ -937,15 +940,17 @@ export function WebShellSidebar({
</span>
{!collapsed && <span>{t('sidebar.settings')}</span>}
</button>
<button
className={styles.collapseButton}
type="button"
title={collapsed ? t('sidebar.expand') : t('sidebar.collapse')}
aria-label={collapsed ? t('sidebar.expand') : t('sidebar.collapse')}
onClick={() => onCollapsedChange(!collapsed)}
>
<IconCollapse collapsed={collapsed} />
</button>
{!mobileOpen && (
<button
className={styles.collapseButton}
type="button"
title={collapsed ? t('sidebar.expand') : t('sidebar.collapse')}
aria-label={collapsed ? t('sidebar.expand') : t('sidebar.collapse')}
onClick={() => onCollapsedChange(!collapsed)}
>
<IconCollapse collapsed={collapsed} />
</button>
)}
</div>
<div
className={styles.resizeHandle}

View file

@ -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',
@ -1621,6 +1622,7 @@ const ZH: Messages = {
'quickActions.exitShellMode': '退出Shell',
'quickActions.setGoal': '设置目标',
'sidebar.label': '工作区侧边栏',
'sidebar.toggleMenu': '切换菜单',
'sidebar.newChat': '新对话',
'sidebar.project': '项目',
'sidebar.projectFallback': '项目',