feat(web-shell): Preview collapsed Home content on hover (#14826)

Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
ytahdnand钉萁 authored and GitHub committed 2026-10-20 13:49:07 +08:00
1 parent 18e6c1d072
commit 158ff26aeb
6 files changed
+404 -79

No files matched your search

@@ -0,0 +1,21 @@
# Collapsed Home hover popover
[English](web-shell-home-hover-popover.md) | [简体中文](web-shell-home-hover-popover.zh-CN.md)
## Problem and scope
The collapsed navigation rail only labels Home on hover. Users need access to the complete expanded Home content without permanently expanding the sidebar. Expanded sidebars retain the existing Home text tooltip, including while another page is open. Home-only layouts and compact drawers retain their existing behavior.
## Proposed behavior
Reuse the existing sidebar hover surface and shared portal-aware Popover for the complete Home column: branding, New task, custom navigation, projects and sessions, and custom footer. Position it to the right of Home, leaving an 8 px gap from the rail, with the saved secondary-column width and a viewport-constrained height. Keep the shared Popover outline without adding a second border. Render one Home tree at a time. Moving between inline and portal presentation can remount child components, as the existing collapsed session surface already does; parent-owned search and session state remain shared.
Hover uses a temporary Home presentation even when Channels or Live is selected. It must not navigate, persist expansion, create a session, or change the composer draft. Clicking Home keeps the current navigation and expansion behavior. Leaving both trigger and content closes after the existing grace period; focus and nested menus keep the surface usable. Escape and outside interaction dismiss it. Layout, page and collapse changes clear the preview.
## Implementation and decisions
Only Web Shell sidebar presentation, styles, and tests change. Extend the internal sidebar hover surface to accept the existing external Home anchor and complete-column presentation, reusing its focus, nested-overlay and dismissal handling. Preserve the portal container and React 18-compatible refs. No daemon routes, package APIs, dependencies, or other navigation previews are added.
## Validation and acceptance
Focused sidebar unit tests cover collapsed preview, unchanged expanded tooltip, complete content, navigation/collapse preservation and cleanup. Browser E2E covers pointer travel, search and menus, dismissal, draft retention, Home/Channels/Live presentation, custom content, compact-layout regression, and dark/light screenshots with a mock daemon. Screenshots demonstrate UI only, not real Git/backend operations. Run repository build, typecheck and bundle plus focused tests. The global CLI baseline is attempted first; use the browser harness if the installed CLI cannot serve this source UI. No open design questions remain.
@@ -0,0 +1,21 @@
# 收起状态下的首页悬浮弹层
[English](web-shell-home-hover-popover.md) | [简体中文](web-shell-home-hover-popover.zh-CN.md)
## 问题与范围
导航栏收起后,悬停首页只显示文字提示。用户需要在不永久展开侧栏的情况下访问首页完整内容。侧栏已展开时,保留现有首页文字提示,包括当前打开其他页面的情况。只有首页的布局以及紧凑抽屉保持现有行为。
## 预期行为
复用侧栏现有悬浮面板和支持 portal 容器的共享 Popover,展示完整首页栏:品牌、新建任务、自定义导航、项目与会话、自定义页脚。在首页右侧显示,与导航栏保留 8 px 间距,使用已保存的二级栏宽度,并限制在视口可用高度内。保留共享 Popover 描边,不叠加第二层边框。同一时刻只渲染一份首页内容。在内联与 portal 呈现之间切换可能重新挂载子组件,与现有收起会话面板一致;父组件持有的搜索和会话状态继续共享。
当前选中频道或 Live 时,悬停临时呈现首页内容。悬停不得导航、持久化展开状态、创建会话或改变输入草稿。点击首页沿用现有导航和展开行为。离开触发器和内容后,使用现有宽限时间关闭;焦点和嵌套菜单应能正常操作。Escape 和外部交互可关闭弹层。布局、页面和收起状态变化时清理预览。
## 实现与决策
仅修改 Web Shell 侧栏呈现、样式和测试。扩展内部侧栏悬浮面板,支持现有外部首页锚点和完整栏呈现,复用焦点、嵌套浮层与关闭逻辑。保留 portal 容器和兼容 React 18 的 ref。不新增 daemon 路由、包 API、依赖或其他导航预览。
## 验证与验收
定向侧栏单元测试覆盖收起预览、展开时文字提示不变、完整内容、导航与收起偏好不变以及状态清理。浏览器 E2E 覆盖鼠标移动、搜索与菜单、关闭、草稿保留、首页/频道/Live 呈现、自定义内容、紧凑布局回归,以及模拟 daemon 下的深浅色截图。截图仅证明 UI,不证明真实 Git 或后端操作。运行仓库构建、类型检查和 bundle,并运行定向测试。先尝试全局 CLI 基线;如已安装 CLI 无法提供此源码 UI,改用浏览器测试入口。无未决设计问题。
@@ -2036,7 +2036,7 @@ describe('WebShellSidebar collapsed session group persistence', () => {
),
).not.toBeNull();
});
it('keeps the Home tree mounted and opens only on click', async () => {
it('previews the complete Home column without expanding or navigating', async () => {
const onCollapsedChange = vi.fn();
const onOpenHome = vi.fn();
renderSidebar(false, { layout: 'rail' });
@@ -2054,19 +2054,87 @@ describe('WebShellSidebar collapsed session group persistence', () => {
const home = container.querySelector<HTMLElement>(
'[data-web-shell-home-trigger]',
)!;
act(() =>
home.dispatchEvent(new MouseEvent('pointerover', { bubbles: true })),
);
const hover = new PointerEvent('pointerover', { bubbles: true });
Object.defineProperty(hover, 'pointerType', { value: 'mouse' });
act(() => home.dispatchEvent(hover));
await flushSidebar();
const preview = document.querySelector('[data-web-shell-home-popover]');
expect(preview).not.toBeNull();
expect(preview!.textContent).toContain('Qwen Code');
expect(preview!.textContent).toContain('New task');
expect(preview!.textContent).toContain('Release notes');
expect(
document.querySelectorAll('[data-web-shell-home-column]'),
).toHaveLength(1);
expect(document.querySelector('[role="tooltip"]')).toBeNull();
expect(onOpenHome).not.toHaveBeenCalled();
expect(onCollapsedChange).not.toHaveBeenCalled();
act(() => click(home));
expect(onCollapsedChange).toHaveBeenCalledWith(false);
expect(onOpenHome).toHaveBeenCalledOnce();
renderSidebar(false, { layout: 'rail' });
await flushSidebar();
expect(column.hidden).toBe(false);
expect(
container.querySelector<HTMLElement>('[data-web-shell-home-column]')!
.hidden,
).toBe(false);
expect(document.querySelector('[data-web-shell-home-popover]')).toBeNull();
expect(groupHeader('Backend').getAttribute('aria-expanded')).toBe('false');
});
it.each(['channels', 'live'])(
'previews Home while %s stays selected',
async (activePage) => {
const onOpenHome = vi.fn();
const capabilities = {
...organizationCapabilities,
features: [
...organizationCapabilities.features,
'session_source_metadata',
],
};
connection.capabilities = capabilities;
workspace.capabilities = capabilities;
renderSidebar(true, {
layout: 'rail',
activePage,
showLive: true,
onOpenLive: vi.fn(),
onOpenHome,
});
await flushSidebar();
expect(
container
.querySelector('[data-web-shell-sidebar-section]')
?.getAttribute('data-web-shell-sidebar-section'),
).toBe(activePage);
const home = container.querySelector('[data-web-shell-home-trigger]')!;
const hover = new PointerEvent('pointerover', { bubbles: true });
Object.defineProperty(hover, 'pointerType', { value: 'mouse' });
act(() => home.dispatchEvent(hover));
await flushSidebar();
expect(
document.querySelector(
'[data-web-shell-home-popover] [data-web-shell-sidebar-section="home"]',
),
).not.toBeNull();
expect(
document.querySelector('[data-web-shell-home-popover]')!.textContent,
).toContain('New task');
expect(
container
.querySelector('button[aria-current="page"]')
?.getAttribute('aria-label'),
).toBe(activePage === 'live' ? 'Live' : 'Channels');
expect(onOpenHome).not.toHaveBeenCalled();
renderSidebar(false, { layout: 'single' });
await flushSidebar();
expect(
document.querySelector('[data-web-shell-home-popover]'),
).toBeNull();
},
);
it('keeps functional navigation selected while Home is hidden', async () => {
const onCollapsedChange = vi.fn();
renderSidebar(true, {
@@ -2395,7 +2463,10 @@ describe('WebShellSidebar rail navigation', () => {
home.dispatchEvent(hover);
});
await flushSidebar();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull();
expect(document.querySelector('[role="tooltip"]')?.textContent).toBe(
'Home',
);
expect(document.querySelector('[data-web-shell-home-popover]')).toBeNull();
renderSidebar(false, { layout: 'single' });
await flushSidebar();
@@ -2399,3 +2399,24 @@ button.livePendingSession:hover {
padding: 10px 4px;
border-bottom: 1px solid var(--sidebar-border);
}
.homePopoverAnchor {
display: contents;
}
.homePopover {
width: min(
var(--collapsed-session-popover-width, 300px),
var(--radix-popover-content-available-width)
);
min-width: 0;
height: var(--radix-popover-content-available-height);
gap: 0;
border: 0;
transition: none;
}
.homePopover .homeColumn {
padding: 12px;
border-right: 0;
}
@@ -10,6 +10,7 @@ import {
type PointerEvent as ReactPointerEvent,
type ReactElement,
type ReactNode,
type RefObject,
} from 'react';
import {
useActions,
@@ -815,6 +816,7 @@ function SidebarSessionSurface({
open,
onOpenChange,
isCloseBlocked,
home,
children: defaultChildren,
content,
header,
@@ -827,6 +829,11 @@ function SidebarSessionSurface({
open: boolean;
onOpenChange: (open: boolean) => void;
isCloseBlocked: () => boolean;
home?: {
anchor: RefObject<HTMLButtonElement | null>;
hidden: boolean;
section: 'home' | 'channels' | 'live';
};
children: ReactNode;
content?: ReactNode;
header?: ReactNode;
@@ -843,6 +850,8 @@ function SidebarSessionSurface({
const focusInsideSurfaceRef = useRef(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const anchorRef = home?.anchor ?? triggerRef;
if (home && open) pointerOpenRef.current = true;
const changeOpen = useCallback(
(nextOpen: boolean) => {
if (!nextOpen && isCloseBlocked()) return;
@@ -885,7 +894,7 @@ function SidebarSessionSurface({
(event.composedPath()[0] as Node | undefined) ?? event.target;
const insideSurface =
target instanceof Node &&
(triggerRef.current?.contains(target) ||
(anchorRef.current?.contains(target) ||
contentRef.current?.contains(target));
const insideNestedOverlay =
target instanceof Element &&
@@ -903,77 +912,105 @@ function SidebarSessionSurface({
document.removeEventListener('pointermove', handlePointerMove);
window.clearTimeout(closeTimerRef.current);
};
}, [cancelClose, closeAfterDelay, collapsed, open]);
}, [anchorRef, cancelClose, closeAfterDelay, collapsed, open]);
if (!collapsed) {
return <div className={styles.sessionList}>{children}</div>;
const surfaceContent = home ? (
<div
className={styles.homeColumn}
hidden={home.hidden && !open}
data-web-shell-home-column
data-web-shell-sidebar-section={home.section}
>
{children}
</div>
) : (
children
);
if (!collapsed || (home && !open)) {
return home ? (
surfaceContent
) : (
<div className={styles.sessionList}>{children}</div>
);
}
return (
<div className={styles.sessionList}>
<div className={home ? styles.homePopoverAnchor : styles.sessionList}>
<Popover open={open} onOpenChange={changeOpen}>
<PopoverTrigger asChild>
<button
ref={triggerRef}
className={styles.pluginButton}
type="button"
aria-label={statusLabel ? `${label}: ${statusLabel}` : label}
data-web-shell-collapsed-session-trigger
onPointerEnter={() => {
// Only an open initiated by the pointer uses hover semantics;
// a graze over a keyboard-opened switcher must not suppress
// focus restoration when it later closes.
if (!open) pointerOpenRef.current = true;
cancelClose();
changeOpen(true);
}}
onPointerLeave={closeAfterDelay}
onPointerDown={() => {
if (!open) pointerOpenRef.current = true;
}}
onClick={(event) => {
event.preventDefault();
cancelClose();
changeOpen(true);
}}
onKeyDown={() => {
pointerOpenRef.current = false;
}}
>
<span className={styles.navIcon}>
<FolderClosedIcon size={16} strokeWidth={1.2} />
{status && (
<span
className={cx(
styles.collapsedSessionStatusDot,
status === 'approval' &&
styles.collapsedSessionStatusApproval,
status === 'question' &&
styles.collapsedSessionStatusQuestion,
)}
data-web-shell-collapsed-session-status={status}
aria-hidden="true"
/>
)}
</span>
</button>
</PopoverTrigger>
{home ? (
home.anchor.current && (
<PopoverAnchor virtualRef={{ current: home.anchor.current }} />
)
) : (
<PopoverTrigger asChild>
<button
ref={triggerRef}
className={styles.pluginButton}
type="button"
aria-label={statusLabel ? `${label}: ${statusLabel}` : label}
data-web-shell-collapsed-session-trigger
onPointerEnter={() => {
// Only an open initiated by the pointer uses hover semantics;
// a graze over a keyboard-opened switcher must not suppress
// focus restoration when it later closes.
if (!open) pointerOpenRef.current = true;
cancelClose();
changeOpen(true);
}}
onPointerLeave={closeAfterDelay}
onPointerDown={() => {
if (!open) pointerOpenRef.current = true;
}}
onClick={(event) => {
event.preventDefault();
cancelClose();
changeOpen(true);
}}
onKeyDown={() => {
pointerOpenRef.current = false;
}}
>
<span className={styles.navIcon}>
<FolderClosedIcon size={16} strokeWidth={1.2} />
{status && (
<span
className={cx(
styles.collapsedSessionStatusDot,
status === 'approval' &&
styles.collapsedSessionStatusApproval,
status === 'question' &&
styles.collapsedSessionStatusQuestion,
)}
data-web-shell-collapsed-session-status={status}
aria-hidden="true"
/>
)}
</span>
</button>
</PopoverTrigger>
)}
<PopoverContent
ref={contentRef}
side="right"
align="start"
sideOffset={8}
sideOffset={home ? 16 : 8}
collisionPadding={8}
onFocus={() => {
focusInsideSurfaceRef.current = true;
}}
className={styles.collapsedSessionPopover}
className={
home
? cx(styles.sidebar, styles.railLayout, styles.homePopover)
: styles.collapsedSessionPopover
}
style={
{
'--collapsed-session-popover-width': `${width}px`,
} as CSSProperties
}
data-web-shell-collapsed-session-switcher
aria-label={home ? label : undefined}
data-web-shell-home-popover={home ? '' : undefined}
data-web-shell-collapsed-session-switcher={home ? undefined : ''}
onOpenAutoFocus={(event) => {
if (pointerOpenRef.current) event.preventDefault();
}}
@@ -993,6 +1030,9 @@ function SidebarSessionSurface({
}}
onEscapeKeyDown={() => {
interactionOpenRef.current = false;
if (home && focusInsideSurfaceRef.current) {
home.anchor.current?.focus();
}
}}
onPointerEnter={cancelClose}
onPointerLeave={closeAfterDelay}
@@ -1021,7 +1061,7 @@ function SidebarSessionSurface({
}
}}
>
{children}
{surfaceContent}
</PopoverContent>
</Popover>
</div>
@@ -1088,6 +1128,8 @@ export function WebShellSidebar({
onStandaloneNotice,
}: WebShellSidebarProps) {
const [moreOpen, setMoreOpen] = useState(false);
const [homePreviewOpen, setHomePreviewOpen] = useState(false);
const homeTriggerRef = useRef<HTMLButtonElement>(null);
const [updateSlot, setUpdateSlot] = useState<HTMLDivElement | null>(null);
const [desktopRelaySlot, setDesktopRelaySlot] =
useState<HTMLDivElement | null>(null);
@@ -1099,6 +1141,7 @@ export function WebShellSidebar({
// returns with a phantom menu the user never reopened.
useEffect(() => {
setMoreOpen(false);
setHomePreviewOpen(false);
}, [mobileOpen, collapsed, activePage, layout]);
const railLayout = layout === 'rail';
const [navigationHint, setNavigationHint] =
@@ -1127,6 +1170,7 @@ export function WebShellSidebar({
) => {
if (railLayout) {
setMoreOpen(false);
setHomePreviewOpen(false);
if (restoresColumn && collapsed) onCollapsedChange(false);
}
open?.();
@@ -1215,14 +1259,16 @@ export function WebShellSidebar({
showSessionSourceSwitch &&
sourceMetadataEnabled &&
primaryNavItems.has('channels');
const channelView = channelNavigationEnabled && activePage === 'channels';
const channelView =
channelNavigationEnabled && activePage === 'channels' && !homePreviewOpen;
const liveNavigationEnabled =
railLayout &&
projectFeaturesEnabled &&
showLive &&
primaryNavItems.has('live') &&
Boolean(onOpenLive);
const liveView = liveNavigationEnabled && activePage === 'live';
const liveView =
liveNavigationEnabled && activePage === 'live' && !homePreviewOpen;
const sectionView = channelView || liveView;
const homeHidden =
railLayout &&
@@ -3164,7 +3210,9 @@ export function WebShellSidebar({
useEffect(() => {
if (railLayout) {
if (homeHidden) {
if (homeHidden && !homePreviewOpen) {
setSearchOpen(false);
setSearchQuery('');
setCollapsedSessionsOpen(false);
cancelRename();
setGroupMenu(null);
@@ -3205,6 +3253,7 @@ export function WebShellSidebar({
collapsedSessionsOpen,
railLayout,
homeHidden,
homePreviewOpen,
liveView,
]);
@@ -6348,7 +6397,12 @@ export function WebShellSidebar({
if (event.pointerType !== 'mouse') return;
const button = (event.target as Element).closest('button');
setNavigationHint(
!moreOpen && event.currentTarget.contains(button)
!moreOpen &&
!(
collapsed &&
button?.hasAttribute('data-web-shell-home-trigger')
) &&
event.currentTarget.contains(button)
? button
: null,
);
@@ -6365,7 +6419,12 @@ export function WebShellSidebar({
onFocusCapture={(event) => {
const button = event.target.closest('button');
setNavigationHint(
!moreOpen && event.currentTarget.contains(button)
!moreOpen &&
!(
collapsed &&
button?.hasAttribute('data-web-shell-home-trigger')
) &&
event.currentTarget.contains(button)
? button
: null,
);
@@ -6386,9 +6445,21 @@ export function WebShellSidebar({
: t('sidebar.home')
}
aria-current={activePage === 'home' ? 'page' : undefined}
aria-expanded={!homeHidden}
aria-expanded={!homeHidden || homePreviewOpen}
aria-haspopup={collapsed ? 'dialog' : undefined}
onClick={() => openNavigation(onOpenHome, true)}
data-web-shell-home-trigger
ref={homeTriggerRef}
onPointerEnter={(event) => {
if (
event.pointerType === 'mouse' &&
collapsed &&
!mobileOpen
) {
setMoreOpen(false);
setHomePreviewOpen(true);
}
}}
>
<span className={styles.navIcon}>
<HouseIcon size={16} strokeWidth={1.2} />
@@ -6493,13 +6564,18 @@ export function WebShellSidebar({
</PopoverContent>
</Popover>
)}
<div
className={styles.homeColumn}
hidden={homeHidden}
data-web-shell-home-column
data-web-shell-sidebar-section={
liveView ? 'live' : channelView ? 'channels' : 'home'
}
<SidebarSessionSurface
collapsed={railLayout && collapsed && !mobileOpen}
label={t('sidebar.home')}
width={sidebarWidth - SIDEBAR_RAIL_WIDTH}
open={homePreviewOpen}
onOpenChange={setHomePreviewOpen}
isCloseBlocked={isCollapsedCloseBlocked}
home={{
anchor: homeTriggerRef,
hidden: homeHidden,
section: liveView ? 'live' : channelView ? 'channels' : 'home',
}}
>
{sectionView && (
<div className={styles.sectionHeader}>
@@ -6534,7 +6610,7 @@ export function WebShellSidebar({
<IconQwenLogo />
))}
</span>
{(!collapsed || mobileOpen) && (
{(!collapsed || mobileOpen || homePreviewOpen) && (
<span className={styles.brandName}>{brandName}</span>
)}
</>
@@ -6559,7 +6635,9 @@ export function WebShellSidebar({
<span className={styles.navIcon}>
<SquarePenIcon size={16} strokeWidth={1.2} />
</span>
{!collapsed && <span>{t('sidebar.newTask')}</span>}
{(!collapsed || homePreviewOpen) && (
<span>{t('sidebar.newTask')}</span>
)}
</button>
</div>
)}
@@ -7327,7 +7405,7 @@ export function WebShellSidebar({
footer.render && (
<div className={styles.homeFooter}>{footer.render()}</div>
)}
</div>
</SidebarSessionSurface>
{!railLayout && footerContent}
<div
className={styles.resizeHandle}
@@ -39,14 +39,42 @@ test('click opens Home and functional pages while collapse retains the draft @sm
).toBeFocused();
await expect(page.locator(homeColumn)).toBeHidden();
await home.hover();
await page.waitForTimeout(350);
await expect(page.locator(homeColumn)).toBeHidden();
const preview = page.locator('[data-web-shell-home-popover]');
await expect(preview).toBeVisible();
await expect(preview.getByText('Qwen Code', { exact: true })).toBeVisible();
await expect(
preview.getByRole('button', { name: 'New task', exact: true }),
).toBeVisible();
await expect(
rail.getByRole('button', { name: 'Expand', exact: true }),
).toBeFocused();
const homeBounds = (await home.boundingBox())!;
const previewBounds = (await preview.boundingBox())!;
expect(previewBounds.x).toBeGreaterThan(homeBounds.x + homeBounds.width);
await expect
.poll(async () => {
const bounds = (await preview.boundingBox())!;
const railBounds = (await rail.boundingBox())!;
return bounds.x - railBounds.x - railBounds.width;
})
.toBeCloseTo(8);
await expect(preview).toHaveCSS('border-left-width', '0px');
await preview.hover();
await page.waitForTimeout(200);
await expect(preview).toBeVisible();
await expect(
rail.getByRole('button', { name: 'Expand', exact: true }),
).toBeVisible();
await expect(
page.locator('[data-web-shell-collapsed-session-switcher]'),
).toHaveCount(0);
await page.screenshot({ path: info.outputPath('collapsed-dark.png') });
await home.click();
await expect(page.locator(homeColumn)).toBeVisible();
await expect(preview).toHaveCount(0);
await page.mouse.move(1000, 500);
await home.hover();
await expect(page.getByRole('tooltip')).toHaveText('Home');
await expect(editor).toHaveText('Keep this unsent draft');
await rail.getByRole('button', { name: 'Plugins', exact: true }).click();
await expect(page.locator(homeColumn)).toBeHidden();
@@ -93,6 +121,91 @@ test('click opens Home and functional pages while collapse retains the draft @sm
).toHaveLength(0);
});
test('Home hover supports search and dismissal without persisting expansion', async ({
page,
baseURL,
}) => {
const scenario = createWebShellDaemonScenario();
await installMockDaemon(page, scenario, { baseURL });
await page.goto('/?language=en-US');
const rail = page.locator(railSelector);
const home = rail.locator('[data-web-shell-home-trigger]');
const preview = page.locator('[data-web-shell-home-popover]');
await rail.getByRole('button', { name: 'Collapse', exact: true }).click();
await home.hover();
await expect(preview).toBeVisible();
await page.mouse.move(1000, 500);
await expect(preview).toHaveCount(0);
await home.hover();
await expect(preview).toBeVisible();
await preview.getByText('Project', { exact: true }).hover();
await preview
.getByRole('button', { name: 'Search sessions', exact: true })
.click();
const search = preview.getByRole('textbox', {
name: 'Search sessions',
exact: true,
});
await search.fill('e2e');
await page.mouse.move(1000, 500);
await page.waitForTimeout(200);
await expect(preview).toBeVisible();
await expect(search).toBeFocused();
await page.keyboard.press('Escape');
await expect(preview).toHaveCount(0);
await expect(home).toBeFocused();
await home.hover();
await expect(preview).toBeVisible();
await expect(
preview.getByRole('textbox', { name: 'Search sessions', exact: true }),
).toHaveCount(0);
const editor = page.locator('[data-web-shell-composer-editor] .cm-content');
await editor.click();
await expect(preview).toHaveCount(0);
await expect(editor).toBeFocused();
await page.reload();
await expect(page.locator(homeColumn)).toBeHidden();
await expect(
rail.getByRole('button', { name: 'Expand', exact: true }),
).toBeVisible();
});
for (const theme of ['dark', 'light']) {
test(`collapsed Home shows custom content in ${theme} and leaves functional pages selected`, async ({
page,
baseURL,
}, info) => {
await page.setViewportSize({ width: 1400, height: 900 });
await installMockDaemon(page, createWebShellDaemonScenario(), { baseURL });
await page.goto(`/e2e/navigation-rail-harness.html?custom&${theme}`);
const rail = page.locator(railSelector);
const home = rail.locator('[data-web-shell-home-trigger]');
await rail.getByRole('button', { name: 'Plugins', exact: true }).click();
await home.hover();
await expect(page.getByRole('tooltip')).toHaveText('Home');
await expect(page.locator('[data-web-shell-home-popover]')).toHaveCount(0);
await rail.getByRole('button', { name: 'Collapse', exact: true }).click();
await home.hover();
const preview = page.locator('[data-web-shell-home-popover]');
await expect(
preview.getByText('Host brand', { exact: true }),
).toBeVisible();
await expect(
preview.getByRole('button', { name: 'Host navigation' }),
).toBeVisible();
await expect(
preview.getByRole('button', { name: 'Host footer' }),
).toBeVisible();
await expect(
rail.getByRole('button', { name: 'Plugins', exact: true }),
).toHaveAttribute('aria-current', 'page');
await expect
.poll(async () => (await preview.boundingBox())?.width)
.toBe(300);
await page.screenshot({ path: info.outputPath(`home-hover-${theme}.png`) });
});
}
test('narrow embedded container uses a contained drawer on a wide viewport @smoke', async ({
page,
baseURL,