diff --git a/.gitignore b/.gitignore index 898d5d9f21..02a14e46bf 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index fa7bdc7c47..ec8fd0a05f 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -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` | diff --git a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx index e47cf84537..0d1999e1bf 100644 --- a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx +++ b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx @@ -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 diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 5e93adf969..a0d4a62e20 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -289,7 +289,9 @@ export const ToolGroupMessage: React.FC = ({ // (>=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 + } /> ); } diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index ce286eac88..bea026bdcf 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -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; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 73e89f0536..5db6521844 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -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({ > { + closeMobileDrawer(); sessionActions .loadSession(sessionId) .catch((error: unknown) => { @@ -3488,16 +3557,62 @@ 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} @@ -937,15 +940,17 @@ export function WebShellSidebar({ {!collapsed && {t('sidebar.settings')}} - + {!mobileOpen && ( + + )}