diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 1380930c7dc..34a4e414f32 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -950,8 +950,35 @@ } .footerButtonLabel { + min-width: 0; white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; +} + +.footerCompact, +.footerTight { + gap: 4px; +} + +.footerCompact .footerButton, +.footerTight .footerButton { + width: 26px; + height: 28px; + flex: 0 0 26px; + justify-content: center; + padding: 0; +} + +.footerCompact .footerButtonLabel, +.footerTight .footerButtonLabel { + display: none; +} + +.footerCompact .collapseButton, +.footerTight .collapseButton { + width: 26px; + height: 28px; } .collapseButton { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx index 066a92efbaf..1ffd00280a0 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.test.tsx @@ -105,6 +105,15 @@ const { WebShellSidebar } = await import('./WebShellSidebar'); const mounted: Array<{ root: Root; container: HTMLElement }> = []; const noop = () => {}; +const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width'; + +function setStoredSidebarWidth(width: number): void { + window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width)); +} + +function pointerEvent(type: string, clientX: number): MouseEvent { + return new MouseEvent(type, { bubbles: true, clientX }); +} function renderSidebar( collapsed: boolean, @@ -116,6 +125,7 @@ function renderSidebar( canOpenSessionsOverview: boolean; onOpenSplitView: () => void; canOpenSplitView: boolean; + onCollapsedChange: (collapsed: boolean) => void; onNewSession: () => Promise | boolean; onLoadSession: (sessionId: string) => Promise | void; onError: (error: unknown, message: string) => void; @@ -153,6 +163,7 @@ function renderSidebar( beforeEach(() => { mockUseSessions.mockClear(); + window.localStorage.clear(); mockConnection.sessionId = null; mockConnection.capabilities = { qwenCodeVersion: '1.2.3', features: [] }; for (const store of [mockActive, mockArchived]) { @@ -196,6 +207,22 @@ afterEach(() => { }); describe('WebShellSidebar — version footer', () => { + it('shows the settings label and qwen-code version at full footer width', () => { + setStoredSidebarWidth(360); + const { container } = renderSidebar(false, { + canOpenSessionsOverview: true, + canOpenSplitView: true, + }); + const settingsButton = container.querySelector( + '[aria-label="Settings"]', + ); + const badge = container.querySelector('[title="Qwen Code v1.2.3"]'); + expect(settingsButton).not.toBeNull(); + expect(settingsButton?.textContent).toContain('Settings'); + expect(badge).not.toBeNull(); + expect(badge?.textContent).toBe('v1.2.3'); + }); + it('shows the qwen-code version in the footer when expanded', () => { const { container } = renderSidebar(false); const badge = container.querySelector('[title="Qwen Code v1.2.3"]'); @@ -203,6 +230,38 @@ describe('WebShellSidebar — version footer', () => { expect(badge?.textContent).toBe('v1.2.3'); }); + it('hides the settings label first while keeping the settings button accessible', () => { + setStoredSidebarWidth(260); + const { container } = renderSidebar(false, { + canOpenSessionsOverview: true, + canOpenSplitView: true, + }); + const settingsButton = container.querySelector( + '[aria-label="Settings"]', + ); + const badge = container.querySelector('[title="Qwen Code v1.2.3"]'); + expect(settingsButton).not.toBeNull(); + expect(settingsButton?.title).toBe('Settings'); + expect(settingsButton?.textContent).not.toContain('Settings'); + expect(settingsButton?.querySelector('svg')).not.toBeNull(); + expect(badge).not.toBeNull(); + }); + + it('hides the version at tight footer width', () => { + setStoredSidebarWidth(220); + const { container } = renderSidebar(false, { + canOpenSessionsOverview: true, + canOpenSplitView: true, + }); + const settingsButton = container.querySelector( + '[aria-label="Settings"]', + ); + expect(settingsButton).not.toBeNull(); + expect(settingsButton?.textContent).not.toContain('Settings'); + expect(container.querySelector('[title="Qwen Code v1.2.3"]')).toBeNull(); + expect(container.textContent ?? '').not.toContain('v1.2.3'); + }); + it('renders a non-semver fallback (e.g. "unknown") without a bogus "v" prefix', () => { mockConnection.capabilities = { qwenCodeVersion: 'unknown' }; const { container } = renderSidebar(false); @@ -331,6 +390,42 @@ describe('WebShellSidebar — split view entry', () => { }); }); +describe('WebShellSidebar — resize behavior', () => { + it('persists normal drag widths without collapsing', () => { + setStoredSidebarWidth(260); + const onCollapsedChange = vi.fn(); + const { container } = renderSidebar(false, { onCollapsedChange }); + const handle = container.querySelector('[role="separator"]'); + expect(handle).not.toBeNull(); + + act(() => { + handle!.dispatchEvent(pointerEvent('pointerdown', 260)); + window.dispatchEvent(pointerEvent('pointermove', 230)); + window.dispatchEvent(pointerEvent('pointerup', 230)); + }); + + expect(onCollapsedChange).not.toHaveBeenCalled(); + expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('230'); + }); + + it('collapses when dragged past the compact threshold and restores the expanded width', () => { + setStoredSidebarWidth(260); + const onCollapsedChange = vi.fn(); + const { container } = renderSidebar(false, { onCollapsedChange }); + const handle = container.querySelector('[role="separator"]'); + expect(handle).not.toBeNull(); + + act(() => { + handle!.dispatchEvent(pointerEvent('pointerdown', 260)); + window.dispatchEvent(pointerEvent('pointermove', 130)); + }); + + expect(onCollapsedChange).toHaveBeenCalledWith(true); + expect(onCollapsedChange).toHaveBeenCalledTimes(1); + expect(window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)).toBe('260'); + }); +}); + function click(el: Element | null): void { expect(el).not.toBeNull(); act(() => { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index f3c93d51627..eca5f720dbe 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -35,6 +35,12 @@ const SIDEBAR_WIDTH_STORAGE_KEY = 'qwen-code-web-shell-sidebar-width'; const SIDEBAR_DEFAULT_WIDTH = 260; const SIDEBAR_MIN_WIDTH = 220; const SIDEBAR_MAX_WIDTH = 420; +const SIDEBAR_FOOTER_COMPACT_WIDTH = 344; +const SIDEBAR_FOOTER_TIGHT_WIDTH = 250; +const SIDEBAR_DRAG_VISUAL_MIN_WIDTH = 200; +const SIDEBAR_COLLAPSE_DRAG_THRESHOLD = 56; +const SIDEBAR_COLLAPSE_DRAG_WIDTH = + SIDEBAR_DRAG_VISUAL_MIN_WIDTH - SIDEBAR_COLLAPSE_DRAG_THRESHOLD; const ACTIVE_SESSION_POLL_INTERVAL_MS = 2000; const IDLE_SESSION_POLL_INTERVAL_MS = 30_000; const DIALOG_SESSION_LABEL_MAX_LENGTH = 96; @@ -170,6 +176,13 @@ function clampSidebarWidth(width: number): number { return Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, width)); } +function clampSidebarVisualWidth(width: number): number { + return Math.min( + SIDEBAR_MAX_WIDTH, + Math.max(SIDEBAR_DRAG_VISUAL_MIN_WIDTH, width), + ); +} + function readSidebarWidth(): number { if (typeof window === 'undefined') return SIDEBAR_DEFAULT_WIDTH; try { @@ -600,6 +613,9 @@ export function WebShellSidebar({ ? `v${qwenCodeVersion}` : qwenCodeVersion : ''; + 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; @@ -1479,6 +1495,8 @@ export function WebShellSidebar({ const startWidth = sidebarWidth; const previousCursor = document.body.style.cursor; const previousUserSelect = document.body.style.userSelect; + let collapsedByDrag = false; + let teardown: (updateState: boolean) => void = () => undefined; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; try { @@ -1486,13 +1504,30 @@ export function WebShellSidebar({ } catch { // Pointer capture is best-effort; window listeners still handle drag. } - const handlePointerMove = (moveEvent: PointerEvent) => { - const nextWidth = clampSidebarWidth( - startWidth + moveEvent.clientX - startX, - ); - setSidebarWidth(nextWidth); - }; - const teardown = (updateState: boolean) => { + function getRawWidth(clientX: number) { + return startWidth + clientX - startX; + } + function restoreExpandedWidth() { + const restoredWidth = clampSidebarWidth(startWidth); + setSidebarWidth(restoredWidth); + writeSidebarWidth(restoredWidth); + } + function collapseFromDrag() { + if (collapsedByDrag) return; + collapsedByDrag = true; + restoreExpandedWidth(); + teardown(true); + onCollapsedChange(true); + } + function handlePointerMove(moveEvent: PointerEvent) { + const rawWidth = getRawWidth(moveEvent.clientX); + if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) { + collapseFromDrag(); + return; + } + setSidebarWidth(clampSidebarVisualWidth(rawWidth)); + } + teardown = function resizeTeardown(updateState: boolean) { document.body.style.cursor = previousCursor; document.body.style.userSelect = previousUserSelect; window.removeEventListener('pointermove', handlePointerMove); @@ -1503,17 +1538,20 @@ export function WebShellSidebar({ setIsResizing(false); } }; - const handlePointerUp = (upEvent: PointerEvent) => { - const nextWidth = clampSidebarWidth( - startWidth + upEvent.clientX - startX, - ); + function handlePointerUp(upEvent: PointerEvent) { + const rawWidth = getRawWidth(upEvent.clientX); + if (rawWidth <= SIDEBAR_COLLAPSE_DRAG_WIDTH) { + collapseFromDrag(); + return; + } + const nextWidth = clampSidebarWidth(rawWidth); setSidebarWidth(nextWidth); writeSidebarWidth(nextWidth); teardown(true); - }; - const handlePointerCancel = () => { + } + function handlePointerCancel() { teardown(true); - }; + } resizeTeardownRef.current = teardown; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp, { once: true }); @@ -1521,7 +1559,7 @@ export function WebShellSidebar({ once: true, }); }, - [collapsed, sidebarWidth], + [collapsed, onCollapsedChange, sidebarWidth], ); const deleteCandidateLabel = deleteCandidate @@ -2506,7 +2544,13 @@ export function WebShellSidebar({ -
+
- {!collapsed && versionLabel && ( + {!collapsed && !footerTight && versionLabel && ( {versionLabel}