feat(web-shell): move session search beside the activity toggle (#16467)

* feat(web-shell): move session search beside the activity toggle

The sidebar search lived in the Workspaces header, where it appeared only
on hover and moved its input into a separate row. Put the toggle in the
top brand row next to View activity, expand the field inline when the
column is wide enough, and fall back to a full-width row under the brand
row for narrow drawers and columns. Hide the control while the activity
view replaces the session list, and keep the collapsed strip's search in
its session popover.

* test(web-shell): scope the drawer brand assertion to the logo

The always-visible search toggle adds a second SVG to the sidebar brand
row, so the drawer smoke test parent-level svg locator matched both the
logo and the search icon and failed in strict mode. Target the logo own
aria-hidden container instead.
This commit is contained in:
Shaojin Wen authored and GitHub committed 2026-11-06 17:38:27 +00:00
1 parent cb282ef5cf
commit ad83860092
4 files changed
+347 -84

No files matched your search

@@ -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<HTMLElement>(
`.${sidebarStyles.topRow}`,
);
expect(topRow).not.toBeNull();
const search = topRow!.querySelector<HTMLButtonElement>(
'button[aria-label="Search sessions"]',
);
const activity = topRow!.querySelector<HTMLButtonElement>(
'button[aria-label="View activity"]',
);
expect(search).not.toBeNull();
expect(activity).not.toBeNull();
expect(search!.parentElement).toBe(activity!.parentElement);
const projectsHeader = container.querySelector<HTMLElement>(
`.${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<HTMLButtonElement>(
'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<HTMLButtonElement>(
'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<HTMLElement>(
`.${sidebarStyles.topRow}`,
);
const search = topRow!.querySelector<HTMLButtonElement>(
'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<HTMLElement>(
`.${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<HTMLElement>(
`.${sidebarStyles.topRow}`,
);
const search = topRow!.querySelector<HTMLButtonElement>(
'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<HTMLElement>(
`.${sidebarStyles.sectionHeader}`,
);
expect(header).not.toBeNull();
expect(header!.textContent).toContain('Channels');
const search = header!.querySelector<HTMLButtonElement>(
'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();
@@ -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;
@@ -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) => (
<div className={cx(styles.projectSearch, className)}>
<SearchIcon aria-hidden="true" />
<Input
value={searchQuery}
placeholder={t('sidebar.searchPlaceholder')}
aria-label={t('sidebar.search')}
autoFocus
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setSearchQuery('');
setSearchOpen(false);
}
}}
/>
</div>
);
const renderSearchToggle = (className: string) => (
<button
type="button"
className={className}
title={t('sidebar.search')}
aria-label={t('sidebar.search')}
aria-expanded={searchOpen}
onClick={() => {
setSearchOpen((open) => {
if (open) setSearchQuery('');
return !open;
});
setProjectsExpanded(true);
}}
>
<SearchIcon size={18} strokeWidth={1.5} />
</button>
);
const primaryNavigation = hasScrollingPrimaryNav && (
<div className={styles.primaryNav}>
{projectFeaturesEnabled &&
@@ -7274,9 +7339,35 @@ export function WebShellSidebar({
}}
>
{sectionView && (
<div className={styles.sectionHeader}>
{t(liveView ? 'sidebar.live' : 'sidebar.channels')}
</div>
<>
<div className={styles.sectionHeader}>
{t(liveView ? 'sidebar.live' : 'sidebar.channels')}
{channelView && searchEnabled && shouldRenderBrand && (
<div
className={cx(
styles.topRowActions,
searchOpen &&
inlineTopRowSearch &&
styles.topRowActionsSearching,
)}
>
{searchOpen &&
inlineTopRowSearch &&
renderSearchField(styles.topRowSearchField)}
{renderSearchToggle(styles.searchToggle)}
</div>
)}
</div>
{channelView &&
searchEnabled &&
shouldRenderBrand &&
searchOpen &&
!inlineTopRowSearch && (
<div className={styles.topRowSearchRow}>
{renderSearchField()}
</div>
)}
</>
)}
{liveView &&
!homeHidden &&
@@ -7288,57 +7379,86 @@ export function WebShellSidebar({
/>
)}
{!sectionView && shouldRenderBrand && (
<div className={styles.topRow}>
{branding?.render ? (
branding.render()
) : (
<>
<span className={styles.brandLogo} aria-hidden="true">
{brand.logo ||
(brand.logoDataUri ? (
// `key` remounts on a new URI: after one decode failure,
// the failed state must not stick to the next logo.
<BrandLogoImage
key={brand.logoDataUri}
dataUri={brand.logoDataUri}
/>
) : (
<IconQwenLogo />
))}
</span>
{(!collapsed || mobileOpen || homePreviewOpen) && (
<span className={styles.brandName}>{brandName}</span>
)}
</>
)}
{activityEnabled && (!railLayout || !collapsed || mobileOpen) && (
<button
type="button"
<>
<div className={styles.topRow}>
{branding?.render ? (
searchOpen && inlineTopRowSearch ? null : (
branding.render()
)
) : (
<>
<span className={styles.brandLogo} aria-hidden="true">
{brand.logo ||
(brand.logoDataUri ? (
// `key` remounts on a new URI: after one decode
// failure, the failed state must not stick to the
// next logo.
<BrandLogoImage
key={brand.logoDataUri}
dataUri={brand.logoDataUri}
/>
) : (
<IconQwenLogo />
))}
</span>
{(!collapsed || mobileOpen || homePreviewOpen) &&
!(searchOpen && inlineTopRowSearch) && (
<span className={styles.brandName}>{brandName}</span>
)}
</>
)}
<div
className={cx(
'ml-auto relative rounded-lg p-2 hover:bg-accent',
styles.activityToggle,
styles.topRowActions,
searchOpen &&
inlineTopRowSearch &&
styles.topRowActionsSearching,
)}
aria-label={t(
activityOpen ? 'activity.close' : 'activity.open',
)}
title={`${t(activityOpen ? 'activity.close' : 'activity.open')} (⌥⌘U / Ctrl+Alt+U)`}
aria-pressed={activityOpen}
onClick={toggleActivity}
>
<BellIcon size={18} strokeWidth={1.5} />
{activityEntries.some(
(entry) =>
isActivityEligible(entry, activityPreferences) &&
activityNeedsAttention(entry),
) && (
<span
className="absolute right-1 top-1 size-1.5 rounded-full bg-primary"
data-activity-attention
/>
{topRowSearchVisible && (
<>
{searchOpen &&
inlineTopRowSearch &&
renderSearchField(styles.topRowSearchField)}
{renderSearchToggle(styles.searchToggle)}
</>
)}
</button>
{activityEnabled &&
(!railLayout || !collapsed || mobileOpen) && (
<button
type="button"
className={cx(
'relative rounded-lg p-2 hover:bg-accent',
styles.activityToggle,
)}
aria-label={t(
activityOpen ? 'activity.close' : 'activity.open',
)}
title={`${t(activityOpen ? 'activity.close' : 'activity.open')} (⌥⌘U / Ctrl+Alt+U)`}
aria-pressed={activityOpen}
onClick={toggleActivity}
>
<BellIcon size={18} strokeWidth={1.5} />
{activityEntries.some(
(entry) =>
isActivityEligible(entry, activityPreferences) &&
activityNeedsAttention(entry),
) && (
<span
className="absolute right-1 top-1 size-1.5 rounded-full bg-primary"
data-activity-attention
/>
)}
</button>
)}
</div>
</div>
{topRowSearchVisible && searchOpen && !inlineTopRowSearch && (
<div className={styles.topRowSearchRow}>
{renderSearchField()}
</div>
)}
</div>
</>
)}
{!sectionView && primaryNavItems.has('newTask') && (
<div
@@ -7633,21 +7753,9 @@ export function WebShellSidebar({
<FolderKanbanIcon />
</button>
)}
<button
className={styles.projectsHeaderAction}
type="button"
title={t('sidebar.search')}
aria-label={t('sidebar.search')}
onClick={() => {
setSearchOpen((open) => {
if (open) setSearchQuery('');
return !open;
});
setProjectsExpanded(true);
}}
>
<SearchIcon />
</button>
{searchEnabled &&
searchInProjectHeader &&
renderSearchToggle(styles.projectsHeaderAction)}
{!channelView &&
!lockedWorkspaceCwd &&
onOpenAddWorkspace && (
@@ -7664,24 +7772,10 @@ export function WebShellSidebar({
</div>
</div>
)}
{!liveView && searchOpen && !hideProjectHeader && (
<div className={styles.projectSearch}>
<SearchIcon aria-hidden="true" />
<Input
value={searchQuery}
placeholder={t('sidebar.searchPlaceholder')}
aria-label={t('sidebar.search')}
autoFocus
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setSearchQuery('');
setSearchOpen(false);
}
}}
/>
</div>
)}
{searchEnabled &&
searchOpen &&
searchInProjectHeader &&
renderSearchField()}
{/* Managed 历史由外部 App Host 注入,不受本地 Workspaces
折叠状态控制;否则折叠分组会把仍有效的 Managed 会话一起隐藏。 */}
{!channelView && !liveView && managedWorkspaces}
@@ -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);