diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx index c1d4f7e20ca..eac766741d6 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx @@ -251,6 +251,133 @@ describe('WebShellSidebar collapsed session group persistence', () => { ); }, ); + it('moves the session search beside the activity toggle in the top row', async () => { + renderSidebar(); + await flushSidebar(); + + const topRow = container.querySelector( + `.${sidebarStyles.topRow}`, + ); + expect(topRow).not.toBeNull(); + const search = topRow!.querySelector( + 'button[aria-label="Search sessions"]', + ); + const activity = topRow!.querySelector( + 'button[aria-label="View activity"]', + ); + expect(search).not.toBeNull(); + expect(activity).not.toBeNull(); + expect(search!.parentElement).toBe(activity!.parentElement); + + const projectsHeader = container.querySelector( + `.${sidebarStyles.projectsHeader}`, + ); + expect(projectsHeader).not.toBeNull(); + expect( + projectsHeader!.querySelector('button[aria-label="Search sessions"]'), + ).toBeNull(); + + click(search!); + await flushSidebar(); + expect( + topRow!.querySelector('input[aria-label="Search sessions"]'), + ).not.toBeNull(); + }); + it('hides the session search while the activity view is open', async () => { + renderSidebar(); + await flushSidebar(); + + const activity = container.querySelector( + 'button[aria-label="View activity"]', + ); + expect(activity).not.toBeNull(); + act(() => click(activity!)); + await flushSidebar(); + + expect( + container.querySelector('button[aria-label="Search sessions"]'), + ).toBeNull(); + const close = container.querySelector( + 'button[aria-label="Close activity view"]', + ); + expect(close).not.toBeNull(); + act(() => click(close!)); + await flushSidebar(); + expect( + container.querySelector('button[aria-label="Search sessions"]'), + ).not.toBeNull(); + }); + it('drops the search field below the icon row in the mobile drawer', async () => { + renderSidebar(false, { mobileOpen: true }); + await flushSidebar(); + + const topRow = container.querySelector( + `.${sidebarStyles.topRow}`, + ); + const search = topRow!.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(search).not.toBeNull(); + act(() => click(search!)); + await flushSidebar(); + + expect( + topRow!.querySelector('input[aria-label="Search sessions"]'), + ).toBeNull(); + const row = container.querySelector( + `.${sidebarStyles.topRowSearchRow}`, + ); + expect(row).not.toBeNull(); + expect( + row!.querySelector('input[aria-label="Search sessions"]'), + ).not.toBeNull(); + }); + it('drops the search field below the icon row in a narrow column', async () => { + window.localStorage.setItem('qwen-code-web-shell-sidebar-width', '276'); + renderSidebar(false, { layout: 'rail' }); + await flushSidebar(); + + const topRow = container.querySelector( + `.${sidebarStyles.topRow}`, + ); + const search = topRow!.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(search).not.toBeNull(); + act(() => click(search!)); + await flushSidebar(); + + expect( + topRow!.querySelector('input[aria-label="Search sessions"]'), + ).toBeNull(); + expect( + container.querySelector(`.${sidebarStyles.topRowSearchRow}`), + ).not.toBeNull(); + }); + it('keeps the session search in the Channels section header', async () => { + connection.capabilities = { + qwenCodeVersion: '1.2.3', + features: ['session_source_metadata', 'session_organization'], + }; + workspace.capabilities = connection.capabilities; + renderSidebar(false, { layout: 'rail', activePage: 'channels' }); + await flushSidebar(); + + const header = container.querySelector( + `.${sidebarStyles.sectionHeader}`, + ); + expect(header).not.toBeNull(); + expect(header!.textContent).toContain('Channels'); + const search = header!.querySelector( + 'button[aria-label="Search sessions"]', + ); + expect(search).not.toBeNull(); + act(() => click(search!)); + await flushSidebar(); + expect( + header!.querySelector('input[aria-label="Search sessions"]'), + ).not.toBeNull(); + }); it('uses drawer constraints and closes mobile without persisting desktop collapse', async () => { const onCollapsedChange = vi.fn(); const onMobileClose = vi.fn(); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css index 182dece75ae..f52f394d1c0 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.module.css @@ -226,6 +226,39 @@ min-width: 0; } +.topRowActions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + margin-left: auto; + min-width: 0; +} + +.topRowActionsSearching { + flex: 1 1 auto; +} + +.topRowSearchRow { + flex: 0 0 auto; + padding: 0 4px 12px; +} + +.searchToggle { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + padding: 8px; + border-radius: 8px; + cursor: pointer; +} + +.searchToggle:hover, +.searchToggle:focus-visible { + background: var(--accent); +} + .brandLogo { flex: 0 0 28px; width: 28px; @@ -511,6 +544,13 @@ button.livePendingSession:hover { padding-left: 32px; } +/* After `.projectSearch`: the top row width is the search field's budget, so + it must be able to shrink instead of pushing the icon buttons out. */ +.topRowSearchField { + flex: 1 1 auto; + min-width: 0; +} + .addWorkspaceButton { display: flex; align-items: center; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index dc520865db0..3d21f9ad711 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -1996,10 +1996,21 @@ export function WebShellSidebar({ }, [workspace.client, lockedWorkspaceCwd, activityEnabled]); const toggleActivity = useCallback(() => { if (!activityEnabled) return; + if (!activityOpen) { + setSearchOpen(false); + setSearchQuery(''); + } setActivityOpen((value) => !value); if (collapsed) onCollapsedChange(false); if (homeHidden) onOpenHome?.(); - }, [activityEnabled, collapsed, homeHidden, onCollapsedChange, onOpenHome]); + }, [ + activityEnabled, + activityOpen, + collapsed, + homeHidden, + onCollapsedChange, + onOpenHome, + ]); useEffect(() => { if (!activityEnabled) return; const listener = (event: KeyboardEvent) => { @@ -6333,6 +6344,60 @@ export function WebShellSidebar({ standaloneSessionsVisible, t, ]); + const searchEnabled = !liveView && !hideProjectHeader; + // The activity panel replaces the session list, so a query typed there + // filters rows the panel never shows. + const topRowSearchVisible = + searchEnabled && + !activityOpen && + (!collapsed || mobileOpen || homePreviewOpen); + // A narrow column cannot fit a usable field beside the icon buttons, so + // there the field drops to a full-width row under the brand row instead. + const topRowColumnWidth = + sidebarWidth - (railLayout ? SIDEBAR_RAIL_WIDTH : 0); + const inlineTopRowSearch = + topRowSearchVisible && !mobileOpen && topRowColumnWidth >= 285; + // The top row owns the search control whenever it is visible. Hosts that + // hide the brand row, and the collapsed strip (whose session list lives in + // a popover), fall back to the older Projects-header placement. + const searchInProjectHeader = + !shouldRenderBrand || (!railLayout && collapsed && !mobileOpen); + const renderSearchField = (className?: string) => ( +
+
+ ); + const renderSearchToggle = (className: string) => ( + + ); const primaryNavigation = hasScrollingPrimaryNav && (
{projectFeaturesEnabled && @@ -7274,9 +7339,35 @@ export function WebShellSidebar({ }} > {sectionView && ( -
- {t(liveView ? 'sidebar.live' : 'sidebar.channels')} -
+ <> +
+ {t(liveView ? 'sidebar.live' : 'sidebar.channels')} + {channelView && searchEnabled && shouldRenderBrand && ( +
+ {searchOpen && + inlineTopRowSearch && + renderSearchField(styles.topRowSearchField)} + {renderSearchToggle(styles.searchToggle)} +
+ )} +
+ {channelView && + searchEnabled && + shouldRenderBrand && + searchOpen && + !inlineTopRowSearch && ( +
+ {renderSearchField()} +
+ )} + )} {liveView && !homeHidden && @@ -7288,57 +7379,86 @@ export function WebShellSidebar({ /> )} {!sectionView && shouldRenderBrand && ( -
- {branding?.render ? ( - branding.render() - ) : ( - <> - - {(!collapsed || mobileOpen || homePreviewOpen) && ( - {brandName} - )} - - )} - {activityEnabled && (!railLayout || !collapsed || mobileOpen) && ( - + {activityEnabled && + (!railLayout || !collapsed || mobileOpen) && ( + + )} +
+
+ {topRowSearchVisible && searchOpen && !inlineTopRowSearch && ( +
+ {renderSearchField()} +
)} - + )} {!sectionView && primaryNavItems.has('newTask') && (
)} - + {searchEnabled && + searchInProjectHeader && + renderSearchToggle(styles.projectsHeaderAction)} {!channelView && !lockedWorkspaceCwd && onOpenAddWorkspace && ( @@ -7664,24 +7772,10 @@ export function WebShellSidebar({
)} - {!liveView && searchOpen && !hideProjectHeader && ( -
-
- )} + {searchEnabled && + searchOpen && + searchInProjectHeader && + renderSearchField()} {/* Managed 历史由外部 App Host 注入,不受本地 Workspaces 折叠状态控制;否则折叠分组会把仍有效的 Managed 会话一起隐藏。 */} {!channelView && !liveView && managedWorkspaces} diff --git a/packages/web-shell/client/e2e/web-shell.navigation-rail.spec.ts b/packages/web-shell/client/e2e/web-shell.navigation-rail.spec.ts index 7cf72ee460a..d9ea8fc7560 100644 --- a/packages/web-shell/client/e2e/web-shell.navigation-rail.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.navigation-rail.spec.ts @@ -228,7 +228,9 @@ test('narrow embedded container uses a contained drawer on a wide viewport @smok .locator(homeColumn) .getByText('Qwen Code', { exact: true }); await expect(brand).toBeVisible(); - await expect(brand.locator('..').locator('svg')).toBeVisible(); + await expect( + brand.locator('..').locator('span[aria-hidden="true"] svg'), + ).toBeVisible(); const host = await page.getByTestId('host-shell').boundingBox(); const bounds = await drawer.boundingBox(); expect(bounds!.x).toBeGreaterThanOrEqual(host!.x);