mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
feat(web-shell): add mobile sidebar drawer with session list (#6003)
* 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:
parent
a756676b21
commit
7b9e31885b
9 changed files with 244 additions and 27 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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': '项目',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue