diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index ad8067117e5..75383d9934b 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -169,6 +169,39 @@ display: none; } +/* Hosts can embed WebShell in a narrow side pane while the browser viewport + * remains wide. The public drawer API must still show a usable overlay in + * that container instead of relying exclusively on the viewport media query. */ +.mobileDrawerForced { + display: block; + /* `.app` is the narrow host Webview's positioning context. Absolute keeps + * the drawer inside it; fixed would target the IDE's larger page instead. */ + position: absolute; + inset: 0; + z-index: 50; + pointer-events: auto; + visibility: visible; + padding-top: env(safe-area-inset-top); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); +} + +.mobileDrawerForced .mobileBackdrop { + display: block; + position: absolute; + inset: 0; + z-index: 49; + background: rgba(0, 0, 0, 0.5); + opacity: 1; + pointer-events: auto; +} + +.mobileDrawerForced > aside { + position: relative; + z-index: 50; +} + .hamburgerButton { display: none; align-items: center; diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index af16d5d63e0..5da641a85c6 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -1506,6 +1506,142 @@ describe('App session callbacks', () => { expect(panel?.getAttribute('aria-label')).toBe('Session Overview'); }); + it('forces the compact session drawer from the external shell ref', async () => { + const shellRef = createRef(); + const { container } = renderApp({ sidebar: true, shellRef }); + await flush(); + + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + + const drawer = container.querySelector( + '[data-sidebar-shell][role="dialog"]', + ); + expect(drawer).not.toBeNull(); + expect(drawer?.className).toContain('mobileDrawerForced'); + }); + + it('returns a forced compact drawer to viewport control when the user dismisses it', async () => { + const shellRef = createRef(); + const { container } = renderApp({ sidebar: true, shellRef }); + await flush(); + + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-sidebar-shell]')?.className, + ).toContain('mobileDrawerForced'); + + await act(async () => { + container + .querySelector( + '[data-sidebar-shell] > div[aria-hidden="true"]', + ) + ?.click(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-sidebar-shell]')?.className, + ).not.toContain('mobileDrawerForced'); + expect( + container.querySelector('[data-sidebar-shell][role="dialog"]'), + ).toBeNull(); + }); + + it('returns to chat and clears the current page when the external shell opens the compact drawer', async () => { + const shellRef = createRef(); + const { container } = renderApp({ sidebar: true, shellRef }); + await flush(); + + await act(async () => { + shellRef.current?.openSessionOverview(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="inline-panel"]'), + ).not.toBeNull(); + + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + expect(container.querySelector('[data-testid="inline-panel"]')).toBeNull(); + + await act(async () => { + shellRef.current?.openSplitView(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).not.toBeNull(); + + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-testid="split-view-page"]'), + ).toBeNull(); + expect( + container.querySelector('[data-sidebar-shell][role="dialog"]'), + ).not.toBeNull(); + }); + + it('clears a forced compact drawer after crossing to a wide viewport', async () => { + let mobileChangeHandler: + | ((event: { matches: boolean }) => void) + | undefined; + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query.includes('min-width'), + media: query, + addEventListener: ( + _type: string, + handler: (event: { matches: boolean }) => void, + ) => { + if (query.includes('max-width')) mobileChangeHandler = handler; + }, + removeEventListener: vi.fn(), + })), + }); + const shellRef = createRef(); + const { container } = renderApp({ sidebar: true, shellRef }); + await flush(); + + await act(async () => { + shellRef.current?.openSessionDrawer(); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-sidebar-shell]')?.className, + ).toContain('mobileDrawerForced'); + + await act(async () => { + mobileChangeHandler?.({ matches: false }); + await Promise.resolve(); + }); + expect( + container.querySelector('[data-sidebar-shell]')?.className, + ).not.toContain('mobileDrawerForced'); + expect( + container.querySelector('[data-sidebar-shell][role="dialog"]'), + ).toBeNull(); + }); + + it('lets a host hide the built-in compact sidebar toggle', async () => { + const { container } = renderApp({ + sidebar: { enabled: true, showCompactToggle: false }, + }); + await flush(); + + expect(container.querySelector('[aria-label="Toggle menu"]')).toBeNull(); + }); + it('returns to the Session Overview when leaving the split view', async () => { const { container } = renderApp(); await flush(); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index d47700959bc..98f81943116 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -100,7 +100,11 @@ import { ThemeDialog } from './components/dialogs/ThemeDialog'; import { DeleteSessionDialog } from './components/dialogs/DeleteSessionDialog'; import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog'; import { RewindDialog } from './components/dialogs/RewindDialog'; -import { WebShellSidebar } from './components/sidebar/WebShellSidebar'; +import { + WebShellSidebar, + type WebShellSidebarBranding, + type WebShellSidebarFooterOptions, +} from './components/sidebar/WebShellSidebar'; import { getLocalCommands, localizeBuiltinDescriptions, @@ -401,6 +405,12 @@ export interface BugReportInfo { export interface WebShellSidebarOptions { enabled?: boolean; defaultCollapsed?: boolean; + /** Whether to show WebShell's built-in compact drawer toggle. Defaults to true. */ + showCompactToggle?: boolean; + /** Hide or replace the leading New Chat brand mark. */ + branding?: false | WebShellSidebarBranding; + /** Hide the footer completely or select the built-in entries it exposes. */ + footer?: false | WebShellSidebarFooterOptions; } export type SessionChangeEvent = @@ -413,6 +423,8 @@ export interface WebShellApi { openSplitView: () => void; /** Open the Session Overview panel, matching the built-in sidebar button. */ openSessionOverview: () => void; + /** Open the compact session drawer, matching the hamburger control. */ + openSessionDrawer: () => void; } export interface WebShellProps { @@ -579,18 +591,25 @@ const CHAT_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-chat-width'; const CHAT_SHELL_HORIZONTAL_PADDING = 40; const SIDEBAR_COLLAPSED_STORAGE_KEY = 'qwen-code-web-shell-sidebar-collapsed'; -function resolveSidebarOptions( - sidebar: WebShellProps['sidebar'], -): Required { +function resolveSidebarOptions(sidebar: WebShellProps['sidebar']): { + enabled: boolean; + defaultCollapsed: boolean; + showCompactToggle: boolean; + branding?: false | WebShellSidebarBranding; + footer?: false | WebShellSidebarFooterOptions; +} { if (sidebar === true) { - return { enabled: true, defaultCollapsed: false }; + return { enabled: true, defaultCollapsed: false, showCompactToggle: true }; } if (!sidebar) { - return { enabled: false, defaultCollapsed: false }; + return { enabled: false, defaultCollapsed: false, showCompactToggle: true }; } return { enabled: sidebar.enabled ?? true, defaultCollapsed: sidebar.defaultCollapsed ?? false, + showCompactToggle: sidebar.showCompactToggle ?? true, + branding: sidebar.branding, + footer: sidebar.footer, }; } @@ -975,7 +994,11 @@ export function App({ string | null >(null); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); - const closeMobileDrawer = useCallback(() => setMobileDrawerOpen(false), []); + const [forceMobileDrawer, setForceMobileDrawer] = useState(false); + const closeMobileDrawer = useCallback(() => { + setMobileDrawerOpen(false); + setForceMobileDrawer(false); + }, []); // The Session Overview panel (mission control for managing many sessions at // once) is only offered on large screens; below that there is no room for it // to be useful. @@ -984,11 +1007,11 @@ export function App({ useEffect(() => { const mql = window.matchMedia('(max-width: 760px)'); const handler = (e: MediaQueryListEvent) => { - if (!e.matches) setMobileDrawerOpen(false); + if (!e.matches) closeMobileDrawer(); }; mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); - }, []); + }, [closeMobileDrawer]); useEffect(() => { if (!mobileDrawerOpen) return; @@ -1999,6 +2022,12 @@ export function App({ () => ({ openSplitView: () => requestOpenSplitView(), openSessionOverview: () => openPanel('sessions'), + openSessionDrawer: () => { + setActivePanel(null); + setMainView('chat'); + setForceMobileDrawer(true); + setMobileDrawerOpen(true); + }, }), [openPanel, requestOpenSplitView], ); @@ -5356,6 +5385,7 @@ export function App({ className={[ styles.mobileDrawer, mobileDrawerOpen ? styles.mobileDrawerOpen : undefined, + forceMobileDrawer ? styles.mobileDrawerForced : undefined, ] .filter(Boolean) .join(' ')} @@ -5403,6 +5433,8 @@ export function App({ sessionListReloadToken={sessionListReloadToken} selectedWorkspaceCwd={selectedWorkspaceCwd} onSelectWorkspace={setSelectedWorkspaceCwd} + branding={sidebarOptions.branding} + footer={sidebarOptions.footer} /> )} @@ -5419,6 +5451,7 @@ export function App({ .join(' ')} > {sidebarOptions.enabled && + sidebarOptions.showCompactToggle && !activePanel && mainView === 'chat' && ( + ) : ( + + {item.label} + + ), + )} + + ); +} + export function WebShellSidebar({ collapsed, onCollapsedChange, @@ -517,6 +835,8 @@ export function WebShellSidebar({ sessionListReloadToken, selectedWorkspaceCwd, onSelectWorkspace, + branding, + footer, }: WebShellSidebarProps) { const { t } = useI18n(); const connection = useConnection(); @@ -609,11 +929,12 @@ export function WebShellSidebar({ }, []); const [searchQuery, setSearchQuery] = useState(''); const [isResizing, setIsResizing] = useState(false); - const [tooltip, setTooltip] = useState<{ - content: ReactNode; - top: number; - left: number; - } | null>(null); + const [tooltip, setTooltip] = useState< + ({ content: ReactNode } & SidebarTooltipPosition) | null + >(null); + const [footerMenuAnchor, setFooterMenuAnchor] = useState( + null, + ); const [completedUnreadIds, setCompletedUnreadIds] = useState>( () => new Set(), ); @@ -639,11 +960,147 @@ export function WebShellSidebar({ : ''; const footerCompact = !collapsed && sidebarWidth < SIDEBAR_FOOTER_COMPACT_WIDTH; - const footerTight = !collapsed && sidebarWidth < SIDEBAR_FOOTER_TIGHT_WIDTH; const sidebarStyle = { '--web-shell-sidebar-width': `${sidebarWidth}px`, } as CSSProperties; const newSessionDisabled = creatingSession; + const shouldRenderBrand = + !collapsed && + branding !== false && + !(mobileOpen && (branding?.hideWhenCompact ?? true)); + const footerActionById: Partial< + Record + > = { + settings: { + id: 'settings', + label: t('sidebar.settings'), + icon: , + onSelect: onOpenSettings, + }, + ...(versionLabel + ? { + version: { + id: 'version' as const, + label: t('sidebar.currentVersion', { version: versionLabel }), + }, + } + : {}), + scheduledTasks: { + id: 'scheduledTasks', + label: t('sidebar.scheduledTasks'), + icon: , + onSelect: onOpenScheduledTasks, + }, + ...(canOpenSessionsOverview + ? { + sessionsOverview: { + id: 'sessionsOverview' as const, + label: t('sidebar.sessionsOverview'), + icon: , + onSelect: onOpenSessions, + }, + } + : {}), + ...(canOpenSplitView + ? { + splitView: { + id: 'splitView' as const, + label: t('sidebar.splitView'), + icon: , + onSelect: onOpenSplitView, + }, + } + : {}), + daemonStatus: { + id: 'daemonStatus', + label: t('sidebar.daemonStatus'), + icon: , + onSelect: onOpenDaemonStatus, + }, + ...(!mobileOpen + ? { + collapse: { + id: 'collapse' as const, + label: collapsed ? t('sidebar.expand') : t('sidebar.collapse'), + icon: , + onSelect: () => onCollapsedChange(!collapsed), + }, + } + : {}), + }; + const requestedFooterItems = + footer === false ? [] : (footer?.items ?? DEFAULT_FOOTER_ITEMS); + const footerActions = requestedFooterItems.reduce( + (actions, item) => { + const action = footerActionById[item]; + if (action && !actions.some((candidate) => candidate.id === item)) { + actions.push(action); + } + return actions; + }, + [], + ); + const footerLayout = resolveFooterLayout( + footerActions.map((action) => action.id), + sidebarWidth, + footerCompact, + collapsed, + ); + const footerInlineActions = footerLayout.inlineItems.flatMap((item) => { + const action = footerActionById[item]; + return action ? [action] : []; + }); + const footerOverflowActions = footerLayout.overflowItems.flatMap((item) => { + const action = footerActionById[item]; + return action ? [action] : []; + }); + const footerOverflowSignature = footerOverflowActions + .map((action) => action.id) + .join(':'); + useEffect(() => { + setFooterMenuAnchor(null); + }, [collapsed, footerOverflowSignature]); + const footerLeadingActions = footerInlineActions.filter( + (action) => action.id === 'settings', + ); + const footerTrailingActions = footerInlineActions.filter( + (action) => action.id !== 'settings', + ); + const renderFooterAction = (action: SidebarFooterAction) => + action.id === 'version' ? ( + + {versionLabel} + + ) : ( + + ); const setSessionBusy = useCallback((sessionId: string, busy: boolean) => { const next = new Set(busySessionIdsRef.current); @@ -867,10 +1324,13 @@ export function WebShellSidebar({ ) => { cancelHideTooltip(); const rect = event.currentTarget.getBoundingClientRect(); + const position = getSidebarTooltipPosition(rect, { + width: window.innerWidth, + height: window.innerHeight, + }); setTooltip({ content, - top: rect.top + rect.height / 2, - left: rect.right + 8, + ...position, }); }, [cancelHideTooltip], @@ -2273,11 +2733,15 @@ export function WebShellSidebar({ > {tooltip && (
)}
- {!collapsed && ( + {shouldRenderBrand && ( )}
-
- - {!collapsed && !footerTight && versionLabel && ( - - {versionLabel} - - )} - - {canOpenSessionsOverview && ( - - )} - {canOpenSplitView && ( - - )} - - {!mobileOpen && ( - - )} -
+ {(footerTrailingActions.length > 0 || + footerOverflowActions.length > 0) && ( +
+ {footerTrailingActions.map(renderFooterAction)} + {footerOverflowActions.length > 0 && ( + + )} +
+ )} + {footerMenuAnchor && footerOverflowActions.length > 0 && ( + setFooterMenuAnchor(null)} + /> + )} +
+ )}
`Current version: ${v?.version ?? ''}`, 'sidebar.archiveCurrentDisabled': 'The current session cannot be archived', 'sidebar.archivedTitle': 'Archived', 'sidebar.archivedEmpty': 'No archived sessions.', @@ -2581,6 +2582,7 @@ const ZH: Messages = { 'sidebar.archive': '归档', 'sidebar.unarchive': '恢复', 'sidebar.moreActions': '更多操作', + 'sidebar.currentVersion': (v) => `当前版本:${v?.version ?? ''}`, 'sidebar.archiveCurrentDisabled': '不能归档当前会话', 'sidebar.archivedTitle': '已归档', 'sidebar.archivedEmpty': '没有已归档的会话。', diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts index 8d4fad44ecc..f6bcb65993e 100644 --- a/packages/web-shell/client/index.ts +++ b/packages/web-shell/client/index.ts @@ -2,9 +2,15 @@ export { App as WebShell } from './App'; export type { WebShellApi, WebShellProps, + WebShellSidebarOptions, BugReportInfo, SessionChangeEvent, } from './App'; +export type { + WebShellSidebarBranding, + WebShellSidebarFooterItem, + WebShellSidebarFooterOptions, +} from './components/sidebar/WebShellSidebar'; export type { TurnOutputKind, TurnOutputOpenRequest,