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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
/>
);
}
Expand Down
83 changes: 83 additions & 0 deletions packages/web-shell/client/App.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,81 @@
overflow: hidden;
}

.mobileDrawer {
display: contents;
}

.mobileBackdrop {
display: none;
}

.hamburgerButton {
display: none;
Comment thread
pomelo-nwu marked this conversation as resolved.
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;
Comment thread
pomelo-nwu marked this conversation as resolved.
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;
}
Comment thread
pomelo-nwu marked this conversation as resolved.

.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;
Comment thread
pomelo-nwu marked this conversation as resolved.
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;
Expand All @@ -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;
Expand Down
135 changes: 125 additions & 10 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,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';
Expand Down Expand Up @@ -808,6 +809,63 @@ export function App({
const [sidebarSwitchingSessionId, setSidebarSwitchingSessionId] = useState<
string | null
>(null);
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
Comment thread
pomelo-nwu marked this conversation as resolved.
const closeMobileDrawer = useCallback(() => setMobileDrawerOpen(false), []);
Comment thread
pomelo-nwu marked this conversation as resolved.

useEffect(() => {
const mql = window.matchMedia('(max-width: 760px)');
Comment thread
pomelo-nwu marked this conversation as resolved.
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When the drawer is open and a tool/permission approval becomes pending, the approval UI renders inside .approvalOverlay in the footer (z-index: 6), which sits behind the drawer's .mobileBackdrop (z-index: 49). The Escape guard here correctly defers to the approval handler, but the approval buttons (Confirm/Deny) are unreachable behind the backdrop. On mobile, the user sees the open drawer and has no indication that an approval is waiting — the only recovery path is tapping the backdrop to close the drawer first.

Consider auto-closing the drawer when an approval appears:

useEffect(() => {
  if (pendingApproval) closeMobileDrawer();
}, [pendingApproval, closeMobileDrawer]);

This ensures the approval UI is always accessible when it matters.

— qwen3.7-max via Qwen Code /review

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) &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The editable-target guard only bails for inputs outside the drawer (!target?.closest('[data-mobile-drawer]')). For the rename input inside the drawer (WebShellSidebar ~line 662), the condition is true && false = false — no early return — so stopPropagation() fires and the drawer closes. The rename input's own Escape handler (cancelRename()) never runs, silently discarding in-progress edits.

The search-input case appears intentional per the code comment, but the rename case is a data-loss edge case. Simplifying the guard to bail for all editable targets (regardless of drawer ancestry) lets both inputs handle Escape first — the drawer closes on the second Escape:

Suggested change
isEditableTarget(target) &&
if (isEditableTarget(target)) {
return;
}

— qwen3.7-max via Qwen Code /review

!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}`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The preventScroll touchmove handler blocks scrolling in portaled dialogs. DialogShell uses createPortal(content, document.body), so dialogs opened from the sidebar (e.g., delete-session confirmation at WebShellSidebar.tsx:779) are outside [data-mobile-drawer]. Touchmove inside these portaled dialogs gets preventDefault() called, blocking native touch scrolling on mobile.

Consider also exempting [data-keyboard-scope] (the attribute DialogShell uses to claim keyboard scope):

Suggested change
!el.closest(`.${styles.mobileBackdrop}`)
!el.closest(`.${styles.mobileBackdrop}`)
) ||
el?.closest('[data-keyboard-scope]')

— glm-5.2 via Qwen Code /review

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The backdrop exclusion in the touchmove handler uses a CSS Modules hashed class name: el.closest(\.${styles.mobileBackdrop}`). This is the only closest()/querySelector()call in the web-shell client that relies on a CSS Modules class — every other element identifier in this handler uses adata- attribute ([data-mobile-drawer]`).

If .mobileBackdrop is renamed in CSS (or a build config change causes the hash to differ), this silently breaks: closest('.undefined') never matches, and the backdrop exclusion stops working — users can scroll the page behind the open drawer on touch devices. A data- attribute would be consistent with the pattern already used one line above and survive CSS refactors.

Suggested change
!el.closest(`.${styles.mobileBackdrop}`)
!el.closest('[data-mobile-backdrop]')

And add data-mobile-backdrop="" to the backdrop element at line 3890:

<div data-mobile-backdrop="" className={styles.mobileBackdrop} onClick={closeMobileDrawer} aria-hidden="true" />

— qwen3.7-max via Qwen Code /review

) {
return;
}
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
window.addEventListener('keydown', onKey, true);
Comment thread
pomelo-nwu marked this conversation as resolved.
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);
Expand Down Expand Up @@ -2074,6 +2132,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
Expand All @@ -2092,11 +2153,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,
Expand All @@ -2108,7 +2173,7 @@ export function App({
throw error;
}
},
[sessionActions],
[closeMobileDrawer, sessionActions],
);

useEffect(() => {
Expand Down Expand Up @@ -2838,10 +2903,12 @@ export function App({
if (cmd === 'resume') {
const sessionId = text.slice(match[0].length).trim();
if (sessionId) {
closeMobileDrawer();
Comment thread
pomelo-nwu marked this conversation as resolved.
sessionActions.loadSession(sessionId).catch((error: unknown) => {
reportError(error, 'Failed to load session');
});
} else {
closeMobileDrawer();
setShowResumeDialog(true);
}
return true;
Expand Down Expand Up @@ -3021,6 +3088,7 @@ export function App({
enqueuePrompt,
echoOrDeferLocalCommand,
branchCurrentSession,
closeMobileDrawer,
createNewSession,
handleBusyGoalClear,
handleGoalSlashCommand,
Expand Down Expand Up @@ -3532,6 +3600,7 @@ export function App({
>
<ResumeDialog
onSelect={(sessionId) => {
closeMobileDrawer();
sessionActions
.loadSession(sessionId)
.catch((error: unknown) => {
Expand Down Expand Up @@ -3804,16 +3873,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')}
Comment thread
pomelo-nwu marked this conversation as resolved.
className={[
styles.mobileDrawer,
mobileDrawerOpen ? styles.mobileDrawerOpen : undefined,
]
Comment thread
pomelo-nwu marked this conversation as resolved.
.filter(Boolean)
.join(' ')}
>
<div
className={styles.mobileBackdrop}
onClick={closeMobileDrawer}
aria-hidden="true"
/>
<WebShellSidebar
collapsed={sidebarCollapsed && !mobileDrawerOpen}
Comment thread
pomelo-nwu marked this conversation as resolved.
onCollapsedChange={handleSidebarCollapsedChange}
onOpenSettings={() => {
Comment thread
pomelo-nwu marked this conversation as resolved.
closeMobileDrawer();
setShowSettingsDialog(true);
}}
onNewSession={createNewSession}
onLoadSession={loadSidebarSession}
onError={reportError}
mobileOpen={mobileDrawerOpen}
/>
</div>
)}
<div className={styles.chatPane}>
{sidebarOptions.enabled && (
<button
type="button"
className={styles.hamburgerButton}
Comment thread
pomelo-nwu marked this conversation as resolved.
Comment thread
pomelo-nwu marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -632,4 +632,16 @@
.sidebar {
display: none;
}

.sidebar.mobileOpen {
Comment thread
pomelo-nwu marked this conversation as resolved.
Comment thread
pomelo-nwu marked this conversation as resolved.
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;
}
Comment thread
pomelo-nwu marked this conversation as resolved.

.resizeHandle {
display: none;
}
}
Loading
Loading